This project is built on the Midnight Network.
https://midnight-private-auction.vercel.app
Current Contract:
f7a1e5df0e42ff659b1e44bc26075bbd705f91facaad5f7a58209067bf90f8f6· 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.
This contract is one of many deployed on Midnight mainnet, across four contract generations (M1–M4). See DEPLOYMENT.md for the full deployment history, 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, auctionId) derived from a local secret, never transmitted — folding in auctionId means the same secret produces a different public key in every auction, so a bidder's activity can't be correlated across auctions |
| 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>, auctionId: Uint<32>): Bytes<32> — per-auction identity, not correlatable across auctions
auctioneerPublicKey(sk: Bytes<32>): Bytes<32> — stable across auctions by design (see below)
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> — caller becomes the auctioneer for this auction
placeBid(auctionId: Uint<32>): [] — any bidder, BIDDING phase
closeAuction(auctionId: Uint<32>, newRevealDeadline: Uint<64>): [] — auctioneer only, sets the reveal deadline
revealBid(auctionId: Uint<32>, amount: Uint<32>, salt: Bytes<32>): [] — any bidder, CLOSED phase, before revealDeadline
claimItem(auctionId: Uint<32>): [] — highest bidder only, after revealDeadline
finalizeAuction(auctionId: Uint<32>): [] — auctioneer only, no valid bids, after revealDeadline
auctioneerPublicKey is deliberately not per-auction, unlike bidderPublicKey: since createAuction is open to anyone, a stable, traceable auctioneer identity across auctions doubles as a lightweight seller-reputation signal (see KNOWN_LIMITATIONS.md).
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.
npm run build always runs a prebuild step first that copies contract/src/managed/auction/{keys,zkir} into frontend/public/{keys,zkir} — the browser fetches its ZK verifier/prover keys from there at runtime, so this keeps them in sync with whatever the contract was last compiled to. Run npm run compile at the repo root before building the frontend if you've changed auction.compact.
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 == auctioneerPublicKey(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. Contract-level design tradeoffs and accepted risks found during self-audit (salt uniqueness, auctioneer-identity correlation, auctioneer neglect) are documented separately in KNOWN_LIMITATIONS.md.
| 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.1.1 |
| 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 |
@midnight-ntwrk/ledger-v8 (8.1.0) and @midnight-ntwrk/onchain-runtime-v3 (3.0.0) — transitive dependencies pulled in by the SDK above — are pinned to exact versions via overrides in package.json (both root and frontend/), not left on a floating ^ range. A clean npm install otherwise resolves newer patch releases of both that were never covered by this project's Independent Reference Model Testing or mainnet e2e verification; see the commit history around the M4 deploy for the incident that motivated this.
Full contract IDs, dates, and verified transaction/block records for every generation below are in DEPLOYMENT.md.
✅ M4 — Self-audit fixes: reveal-window enforcement, identity isolation — Current
revealBid,claimItem, andfinalizeAuctionnow enforce a reveal deadline (previously unbounded — a bid could be revealed, or an item claimed, at any time after closing)closeAuctionnow sets the reveal deadline itself (newRevealDeadline) instead of relying on the value guessed atcreateAuctiontimecreateAuctionvalidatesrevealDeadline > endTimebidderPublicKeynow folds inauctionId, so a bidder's identity can no longer be correlated across auctions (auctioneerPublicKeysplit out as its own, intentionally cross-auction-stable circuit)- Verified via Independent Reference Model Testing (6,060 real-contract comparisons, all MATCH) and a scripted mainnet e2e run — see verification/REPORT.md
✅ M3 — Item descriptions, reserve price, timed auctions — Superseded
- 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 — Superseded
- 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 — Superseded
- 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