This project is built on the Midnight Network.
https://midnight-private-auction.vercel.app
Current Contract:
4fd31443997bd04bbf0b94e2ef3d5b0ff05479c4fb80bcac0dc74b2c763282e5Β· Full deployment history, tx hashes, and block numbers: DEPLOYMENT.md
Sealed-bid auction on Midnight Network. During the bidding phase, bid amounts and bidder identities are hidden by ZK proofs β chain observers can see that a placeBid() call occurred, but not who made it or how much they bid. The amount only appears on-chain when the bidder voluntarily calls revealBid(). This is a commit-reveal auction implemented as a Compact smart contract, purpose-built for Midnight's ZK circuit model β not a port from an EVM contract.
As of block 1,498,136 (~3 months after Midnight mainnet launch), this contract is one of 114 contracts deployed on Midnight mainnet. See DEPLOYMENT.md for the full deployment history across three contract generations (M1βM3), including every verified transaction hash and block number.
This project is not ported from another chain. Every design decision maps to a Midnight-native capability.
The structural reason: an EVM chain reaches consensus by having every validator re-execute a transaction against fully public state β so every input to that execution (sender, calldata, storage reads) must be public by construction. There is no way to keep an input private and still let validators independently verify the result.
Midnight breaks that coupling. A Compact contract compiles to a ZK circuit; the party submitting a transaction generates a proof (via the local Proof Server) that a valid state transition happened, using both public ledger state and private witness data as circuit inputs. Validators then check the proof, not the private inputs. Correctness is verified without the private data ever being reconstructible from what's on-chain. Bid privacy here isn't a feature bolted onto a public-by-default chain β it falls directly out of how Midnight validates transactions.
| Problem | Generic EVM approach | Midnight-native approach |
|---|---|---|
| Bid privacy during bidding | Bids are public in calldata the moment they're sent | Bid amount lives in Compact private state (witness); only a ZK commitment hash goes on-chain |
| Preventing frontrunning | Manual commit-hash schemes hand-rolled in Solidity | Privacy enforced by the Compact compiler and ZK circuit β no manual scheme needed |
| Bidder identity | EOA address trivially linkable across bids | Domain-separated bidderPublicKey = H("auction:bidder:", sk) derived from a local secret, never transmitted |
| Reveal integrity | Trust event logs or off-chain computation | ZK circuit asserts H(sk, auctionId, amount, salt) == stored commitment before accepting a reveal |
| Double-bid prevention | Requires an explicit "hasBid" mapping keyed by msg.sender |
Nullifier-style on-circuit assertion: placeBid rejects a second bid the moment the caller's derived bidderPublicKey is already a key in that auction's sealedBids map |
Two things are true on Midnight at once: everyone can see the ledger, and no one but the bidder can see the bid. The diagram below shows what crosses that boundary and what never does.
The circuit itself is compiled once from contract/src/auction.compact down to WASM-executable ZK IR (.zkir files) plus per-circuit prover/verifier key pairs, committed under contract/src/managed/auction/. Every placeBid, revealBid, etc. call runs its circuit's WASM through the local Proof Server to produce a proof, which is what actually gets submitted on-chain β the circuit logic never runs on a public node.
placeBid() computes commitment = persistentHash("auction:seal:", sk, auctionId, amount, salt) entirely inside the ZK circuit. amount and salt are Compact witness values β they are never serialised into the transaction or posted to the indexer. Chain observers learn only that a valid sealed bid exists for a given bidderPK in a given auction.
Compact's witness declarations (localSecretKey, myBidAmount, myBidSalt) act as a type-safe private state vault, distinct from the public ledger declarations. The TypeScript SDK stores these locally in LevelDB per role identity (auctioneer, bidder1, bidder2) and passes them to the circuit at prove-time β they never leave the prover's machine.
revealBid(amount, salt) recomputes the commitment inside the circuit and asserts it matches the stored on-chain hash before updating the leaderboard. Two enforcement mechanisms make this safe without any off-chain bookkeeping:
- Single-bid enforcement, checked in
placeBid: once a bidder's derived public key exists as a key insealedBidsfor an auction, the circuit rejects a secondplaceBidcall from that same key β functionally a nullifier, scoped per-auction. - Disclosure-ordering enforcement, checked by the Compact compiler itself in
revealBid:disclose(amount)must happen unconditionally before theif (pubAmount > highestBid)comparison, so branch outcome never leaks unrevealed information (see Implementation Notes).
File: contract/src/auction.compact Β· Compact pragma >= 0.20; committed build artifacts were compiled with compact compiler 0.31.0 / language version 0.23.0 (see contract/src/managed/auction/compiler/contract-info.json).
// Pure circuits (computation only, no proof, no state change)
bidderPublicKey(sk: Bytes<32>): Bytes<32>
computeCommitment(sk: Bytes<32>, auctionId: Uint<32>, amount: Uint<32>, salt: Bytes<32>): Bytes<32>
// Impure circuits (proof required, ledger state changes)
createAuction(item: Opaque<"string">, desc: Opaque<"string">, startPrice: Uint<32>,
auctionEndTime: Uint<64>, auctionRevealDeadline: Uint<64>): Uint<32> β auctioneer only
placeBid(auctionId: Uint<32>): [] β any bidder, BIDDING phase
closeAuction(auctionId: Uint<32>): [] β auctioneer only
revealBid(auctionId: Uint<32>, amount: Uint<32>, salt: Bytes<32>): [] β any bidder, CLOSED phase
claimItem(auctionId: Uint<32>): [] β highest bidder only
finalizeAuction(auctionId: Uint<32>): [] β auctioneer only, no valid bids
Ledger state is a set of Map<Uint<32>, ...> keyed by auctionId β phase, itemName, description, startingPrice, endTime, revealDeadline, auctioneerPK, sealedBids, bidCount, highestBidderPK, highestBid, itemClaimed β plus a single global nextAuctionId: Counter. Every auction's state is independent, so one contract deployment hosts many concurrent auctions.
Auction phase transitions
VACANT ββcreateAuction()βββΊ BIDDING ββcloseAuction()βββΊ CLOSED
β
highestBid > 0 ββββΌββ claimItem() (highest bidder)
highestBid == 0 βββ΄ββ finalizeAuction() (auctioneer reclaims item)
Prerequisites
- Node.js β₯ 22
compactcompiler inPATH- Midnight Proof Server running locally (default port 6300)
- Night tokens β Preprod: faucet; Mainnet: real tokens
git clone git@github.com:pplmaverick/midnight-private-auction.git
cd midnight-private-auction
npm installEnvironment variables
| Variable | Required | Description |
|---|---|---|
WALLET_SEED |
Optional | Hex seed to reuse an existing wallet; if unset, a fresh wallet is generated |
MIDNIGHT_NETWORK |
Optional | preprod (default) or mainnet |
MIDNIGHT_PROOF_SERVER |
Optional | Override proof server URL (default: http://127.0.0.1:6300) |
MIDNIGHT_INDEXER |
Mainnet only | Indexer GraphQL HTTP endpoint |
MIDNIGHT_INDEXER_WS |
Mainnet only | Indexer GraphQL WebSocket endpoint |
MIDNIGHT_NODE |
Mainnet only | Node RPC endpoint |
Deploy-specific variables (MIDNIGHT_DEPLOY_NODE, funding requirements) are covered in DEPLOYMENT.md.
# Recompile contract from source (pre-compiled artifacts are committed)
npm run compile
# Run on Preprod
WALLET_SEED=<hex> npm run preprod
# Run on Mainnet
MIDNIGHT_INDEXER=<url> MIDNIGHT_INDEXER_WS=<url> MIDNIGHT_NODE=<url> \
WALLET_SEED=<hex> npm run mainnetWallet sync phases
On first run the wallet must sync from genesis. The script handles this automatically in three phases:
| Phase | What happens | Time | Peak RAM |
|---|---|---|---|
| Phase 1 | DustWallet genesis sync; ShieldedWallet deliberately idle via stub | 10β20 min | ~8 GB |
| Phase 2 | ShieldedWallet genesis sync; DustWallet restores from checkpoint | 10β20 min | ~7 GB |
| Phase 3 | Both wallets restore from saved checkpoints β fast path | < 30 sec | < 1 GB |
Checkpoints are saved to .wallet-state/ (git-ignored). Subsequent runs go straight to Phase 3.
cd frontend
npm install
npm run dev # local dev server (Vite)
npm run build # production build, output in frontend/distThe frontend is a React 19 + Vite single-page app that connects to a browser wallet extension (e.g. 1AM, Lace) via @midnight-ntwrk/dapp-connector-api and talks to the deployed contract using the same @midnight-ntwrk/midnight-js stack as the backend scripts. It's deployed to Vercel β see vercel.json for the build configuration.
Privacy boundary
What a chain observer (or block explorer) can see for each placeBid() transaction:
| Observable | Visible? | Notes |
|---|---|---|
Function called (placeBid) |
β Yes | Transaction metadata is public |
| When it occurred (block number) | β Yes | Transaction metadata is public |
| Fee paid | β Yes | DUST fee amount is public |
| Sender / bidder address | β No | No "from" field β DUST fee is paid via shielded mechanism |
| Bid amount | β No | Compact witness β never serialised into the transaction |
| Commitment hash preimage | β No | Without (sk, auctionId, amount, salt) the on-chain hash reveals nothing |
Compare with an equivalent EVM contract: placeBid(uint256 amount, bytes32 salt) would expose both the sender address and the bid amount in calldata, permanently and publicly. On Midnight, the function name is visible but the meaningful data (who and how much) is not.
Commitment properties
- Commitment binding: each commitment is tied to
localSecretKeyandauctionIdβ a bidder cannot replay another bidder's commitment, or replay their own commitment across auctions - Commitment hiding: without all four of
sk,auctionId,amount, andsalt, the on-chain hash reveals nothing - Auctioneer auth:
closeAuction()andfinalizeAuction()assertauctioneerPK == bidderPublicKey(localSecretKey())inside the ZK circuit β no external role system needed - Claim guard:
claimItem()asserts the caller's derived public key equalshighestBidderPKandhighestBid > 0and!itemClaimed - No private key on-chain: all secret material stays in Compact
witnessβ never serialised into any transaction - Single-bid / nullifier enforcement:
placeBid()asserts the caller has not previously submitted a sealed bid for this auction β each bidder may place exactly one bid per auction, enforced on-circuit, not by an off-chain check
disclose() placement constraint in Compact
The Compact compiler enforces that disclose() cannot appear inside a conditional branch β if it did, chain observers could infer the branch outcome from whether a disclosure event fired. In revealBid, this means amount must be disclosed unconditionally before the if (pubAmount > highestBid) comparison:
const pubAmount = disclose(amount); // disclose first β required by compiler
if (pubAmount > highestBid) { // comparison is between two already-public values
highestBid = pubAmount;
Attempting if (amount > highestBid) { highestBid = disclose(amount); } fails to compile with a hard error. Since revealBid is the reveal phase, disclosing unconditionally is correct by design.
Deployment-specific limitations (public RPC transaction size limits, wallet WASM memory behavior, SDK signing workarounds) are documented in DEPLOYMENT.md Β§ Known Limitations.
| Layer | Technology |
|---|---|
| Smart contract | Compact (pragma β₯0.20; compiled with compiler 0.31.0 / language 0.23.0) |
| ZK backend | Midnight Proof Server (local), WASM-executed .zkir circuits |
| Runtime SDK | @midnight-ntwrk/midnight-js ^4.0.4 |
| Wallet (backend scripts) | @midnight-ntwrk/wallet-sdk-facade ^3.0.0 (Shielded + Dust + Unshielded) |
| Private state storage | LevelDB via midnight-js-level-private-state-provider |
| Backend language | TypeScript (ESM, Node.js β₯ 22) |
| Frontend | React 19, Vite, Tailwind CSS 4, @midnight-ntwrk/dapp-connector-api |
| Frontend hosting | Vercel |
Full contract IDs, dates, and verified transaction/block records for every generation below are in DEPLOYMENT.md.
β M3 β Item descriptions, reserve price, timed auctions β Current
- Added
description,startingPrice,endTime,revealDeadlineledger fields, keyed per auction - Added
finalizeAuctioncircuit β auctioneer reclaims the item if no valid bids were revealed by the reveal deadline revealBidnow enforces that revealed bids meet the auction's starting price
β M2 β Multi-auction contract redesign β Deprecated
- Redesigned from single-auction to multi-auction architecture
- Each auction identified by an auto-incremented
auctionId(no ID collision) - Single-bid enforcement per bidder per auction (on-circuit assertion)
β M1 β Sealed-bid demo, fully verified on mainnet β Deprecated
- Compact contract with ZK commit-reveal privacy model
- Full 7-step e2e verified on Midnight mainnet: deploy β bid (Γ2) β close β reveal (Γ2) β claim
- 3-phase wallet sync with checkpoint persistence, WASM memory guard,
MIDNIGHT_DEPLOY_NODErouting
GitHub: pplmaverick
MIT