DEV Community

Tosh
Tosh

Posted on

Building Private NFT Marketplaces on Midnight: Hidden Ownership, Anonymous Trading

Building Private NFT Marketplaces on Midnight: Hidden Ownership, Anonymous Trading

NFT ownership on transparent blockchains is a surveillance tool masquerading as a cultural artifact. Every token transfer is publicly logged: who sold, who bought, for how much, and at what time. Any sufficiently funded adversary can build a complete economic profile of any wallet — not just their NFT history but, by extension, their entire financial behavior. Ownership of certain NFTs can reveal political affiliations, sexual orientation, religious beliefs, or simply make someone a target for theft.

The privacy case for NFTs isn't academic. High-value collectors routinely use intermediaries and obfuscation to avoid advertising holdings. Journalists use tokenized credentials. Medical professionals might hold certified access tokens. In all these cases, the natural unit of privacy isn't "hide the content" — the content is often public art — it's "hide who holds it."

Midnight offers the cryptographic tools to build NFT systems where ownership is a private state, transfers are proven without revealing buyer or seller, and prices are never visible on-chain. This article covers the full design: ownership representation, private listings, sealed bid auctions, royalty enforcement, and a complete Compact contract sketch.


NFT Ownership as Private State

On EVM chains, NFT ownership is a simple mapping: tokenId => owner_address. It's a public ledger entry readable by anyone.

On Midnight, we invert this. Ownership is a commitment: hash(token_id, owner_pubkey, secret). The chain stores commitments in a Merkle tree; it does not store token_id => owner mappings. To prove you own an NFT, you prove you know the preimage of a commitment in the tree without revealing that preimage.

ledger {
  // Ownership commitments tree
  ownership_tree: MerkleTree<Field, 20>,

  // Spent commitments (transferred away)
  transfer_nullifiers: Set<Field>,

  // NFT metadata (public — the art/attributes)
  nft_metadata: Map<TokenId, NFTMetadata>,

  // Token existence registry (minting record — public)
  minted_tokens: Set<TokenId>,
}

type NFTMetadata = {
  name: String,
  uri: String,           // IPFS/Arweave link to content
  creator: PublicKey,    // public knowledge
  royalty_bps: Uint<16>, // basis points (e.g., 500 = 5%)
}

// Ownership note — stored client-side only
type OwnershipNote = {
  token_id: TokenId,
  secret: Field,
  owner_pubkey: PublicKey,
  tree_position: Uint<32>,
  merkle_path: MerklePath<20>,
}
Enter fullscreen mode Exit fullscreen mode

When an NFT is minted, the creator generates an ownership commitment and inserts it. The token ID and metadata are public; the owner is not.

circuit mint_nft(
  witness token_id: TokenId,
  witness creator_pubkey: PublicKey,
  witness secret: Field,

  public ownership_commitment: Field,
  public token_id_public: TokenId
) {
  assert token_id == token_id_public;

  // Bind ownership to creator at mint
  let commitment = hash(token_id as Field, pubkey_to_field(creator_pubkey), secret);
  assert commitment == ownership_commitment;
}

contract PrivateNFT {
  fn mint(
    proof: ZKProof,
    token_id: TokenId,
    metadata: NFTMetadata,
    ownership_commitment: Field
  ) {
    assert !ledger.minted_tokens.contains(token_id);

    verify_proof(proof, mint_nft_circuit, [ownership_commitment, token_id]);

    ledger.minted_tokens.insert(token_id);
    ledger.nft_metadata[token_id] = metadata;
    ledger.ownership_tree.insert(ownership_commitment);
  }
}
Enter fullscreen mode Exit fullscreen mode

Private Transfers: Proven Without Revealing Buyer or Seller

A private transfer nullifies the sender's ownership commitment and creates a new one for the recipient. Neither the sender's nor recipient's identity needs to be public.

The challenge: the recipient must receive their new ownership note somehow. This requires an out-of-band channel or an on-chain encrypted memo.

circuit private_transfer(
  // Sender's private inputs
  witness token_id: TokenId,
  witness sender_secret: Field,
  witness sender_privkey: PrivateKey,
  witness merkle_path: MerklePath<20>,

  // Recipient info (shared via private channel)
  witness recipient_pubkey: PublicKey,
  witness recipient_secret: Field,

  // Optional: payment (if part of atomic swap)
  witness payment_amount: Uint<64>,
  witness payment_note: PaymentNote,

  // Public outputs
  public transfer_nullifier: Field,
  public new_ownership_commitment: Field,
  public token_id_public: TokenId
) {
  let sender_pubkey = derive_pubkey(sender_privkey);

  // Verify sender holds the NFT
  let old_commitment = hash(token_id as Field, pubkey_to_field(sender_pubkey), sender_secret);
  assert merkle_verify(ledger.ownership_tree.root(), old_commitment, merkle_path);

  // Compute nullifier
  assert hash(sender_secret, old_commitment) == transfer_nullifier;

  // Create new commitment for recipient
  let new_commitment = hash(token_id as Field, pubkey_to_field(recipient_pubkey), recipient_secret);
  assert new_commitment == new_ownership_commitment;

  // Token ID is public (provenance chain)
  assert token_id == token_id_public;

  // If payment is attached, verify it (atomic swap)
  // This requires a payment circuit integrated here — see the AMM article for patterns
}

contract PrivateNFT {
  fn transfer(
    proof: ZKProof,
    transfer_nullifier: Field,
    new_commitment: Field,
    token_id: TokenId
  ) {
    assert ledger.minted_tokens.contains(token_id);
    assert !ledger.transfer_nullifiers.contains(transfer_nullifier);

    verify_proof(proof, private_transfer_circuit, 
      [transfer_nullifier, new_commitment, token_id]);

    ledger.transfer_nullifiers.insert(transfer_nullifier);
    ledger.ownership_tree.insert(new_commitment);
    // Old commitment stays in tree (Merkle trees are append-only)
    // Nullifier proves it's spent
  }
}
Enter fullscreen mode Exit fullscreen mode

What the chain sees after a transfer:

  • A nullifier was published (opaque hash)
  • A new leaf was added to the ownership tree (opaque commitment)
  • The token ID referenced in the proof (public)

What stays hidden: who transferred, who received, and (if using private payment) the price.


Listing Without Revealing Asking Price

A standard NFT listing on OpenSea is completely public: "Alice is selling Token #4521 for 2 ETH." On a private marketplace, we want to list availability without revealing the price.

The mechanism: the seller creates a listing commitment — a hash of the token ID, the asking price, and a secret. Buyers must discover the price via a private channel (direct message, encrypted marketplace API) and prove they're offering the right amount.

ledger {
  // Active listings: token_id => listing commitment
  listings: Map<TokenId, ListingCommitment>,
}

type ListingCommitment = Field; // hash(token_id, ask_price, seller_pubkey, secret)

circuit create_listing(
  witness token_id: TokenId,
  witness ask_price: Uint<64>,
  witness seller_privkey: PrivateKey,
  witness ownership_secret: Field,
  witness listing_secret: Field,
  witness ownership_path: MerklePath<20>,

  public listing_commitment: Field,
  public token_id_public: TokenId
) {
  let seller_pubkey = derive_pubkey(seller_privkey);

  // Verify seller owns the NFT
  let ownership_commitment = hash(token_id as Field, pubkey_to_field(seller_pubkey), ownership_secret);
  assert merkle_verify(ledger.ownership_tree.root(), ownership_commitment, ownership_path);

  // Create listing commitment (ask_price is private)
  let computed_listing = hash(token_id as Field, ask_price as Field, 
                               pubkey_to_field(seller_pubkey), listing_secret);
  assert computed_listing == listing_commitment;
  assert token_id == token_id_public;
}
Enter fullscreen mode Exit fullscreen mode

A buyer who knows the ask price (from an off-chain channel) can fulfill the listing:

circuit fulfill_listing(
  // Seller's listing info (buyer received this privately)
  witness token_id: TokenId,
  witness ask_price: Uint<64>,
  witness seller_pubkey: PublicKey,
  witness listing_secret: Field,

  // Buyer's credentials
  witness buyer_privkey: PrivateKey,
  witness buyer_payment_note: PaymentNote,
  witness ownership_secret: Field,    // new ownership secret for buyer

  public listing_commitment_match: Field,  // must match stored listing
  public transfer_nullifier: Field,        // nullifies seller's ownership
  public new_ownership_commitment: Field,
  public payment_nullifier: Field          // proves payment was made
) {
  // Verify listing commitment matches
  let computed_listing = hash(token_id as Field, ask_price as Field,
                               pubkey_to_field(seller_pubkey), listing_secret);
  assert computed_listing == listing_commitment_match;

  let buyer_pubkey = derive_pubkey(buyer_privkey);

  // Verify payment (buyer proves they're spending ask_price)
  // Payment circuit verification integrated here
  assert verify_payment(buyer_payment_note, ask_price, payment_nullifier);

  // New ownership for buyer
  let new_commitment = hash(token_id as Field, pubkey_to_field(buyer_pubkey), ownership_secret);
  assert new_commitment == new_ownership_commitment;
}
Enter fullscreen mode Exit fullscreen mode

The ask price never appears on-chain. An observer sees: a listing commitment was created and later fulfilled. No price. No parties.


Sealed Bid Auction on Midnight

A more sophisticated listing mechanism: sealed bid auction where bids are hidden until reveal time.

Phase 1: Commit (during auction window)

Bidders submit bid_commitment = hash(bid_amount, bidder_pubkey, nonce). No amounts are visible.

Phase 2: Reveal

Bidders reveal their bids by publishing (bid_amount, nonce). The circuit verifies the reveal matches the commitment.

Phase 3: Settlement

The highest valid bid wins. The winner's payment nullifier is consumed, and they receive ownership. Losing bidders receive their funds back.

ledger {
  auctions: Map<AuctionId, Auction>,
  bid_commitments: Map<AuctionId, Vec<BidCommitment>>,
}

type Auction = {
  token_id: TokenId,
  seller_listing_commitment: Field,
  bid_deadline: BlockHeight,
  reveal_deadline: BlockHeight,
  highest_bid: Uint<64>,        // revealed after reveal phase
  winner_commitment: Field,
  settled: Bool,
}

circuit commit_bid(
  witness bid_amount: Uint<64>,
  witness bidder_privkey: PrivateKey,
  witness nonce: Field,
  witness payment_proof: PaymentProof,  // escrow payment

  public bid_commitment: Field,
  public auction_id: AuctionId
) {
  let bidder_pubkey = derive_pubkey(bidder_privkey);
  let computed_commitment = hash(bid_amount as Field, pubkey_to_field(bidder_pubkey), nonce);
  assert computed_commitment == bid_commitment;

  // Bidder must escrow bid_amount (prevents non-binding bids)
  assert verify_payment_escrow(payment_proof, bid_amount);

  assert bid_amount > 0;
}

circuit reveal_bid(
  witness bid_amount: Uint<64>,
  witness bidder_privkey: PrivateKey,
  witness nonce: Field,

  public bid_commitment: Field,   // must match previously submitted commitment
  public revealed_amount: Uint<64>,
  public auction_id: AuctionId
) {
  let bidder_pubkey = derive_pubkey(bidder_privkey);
  let computed_commitment = hash(bid_amount as Field, pubkey_to_field(bidder_pubkey), nonce);
  assert computed_commitment == bid_commitment;
  assert bid_amount == revealed_amount;
}
Enter fullscreen mode Exit fullscreen mode

The auction contract tracks revealed bids and determines the winner after the reveal deadline. The winning bidder's payment is transferred to the seller; losing bidders' escrowed funds are released.


Private Royalty Enforcement

Creator royalties are a persistent problem in NFT markets: on transparent chains, royalties can be enforced on-chain (as OpenSea has tried) or circumvented by transferring via non-royalty-aware contracts.

On a private marketplace, royalty enforcement is handled in the transfer circuit itself. The transfer is only valid if the royalty has been paid — the circuit constrains the payment structure.

circuit transfer_with_royalty(
  // Ownership transfer inputs (as above)
  witness token_id: TokenId,
  witness sender_secret: Field,
  witness sender_privkey: PrivateKey,
  witness merkle_path: MerklePath<20>,
  witness recipient_pubkey: PublicKey,
  witness recipient_secret: Field,

  // Sale price and royalty (private — not revealed on chain)
  witness sale_price: Uint<64>,
  witness royalty_payment_proof: PaymentProof,

  public transfer_nullifier: Field,
  public new_ownership_commitment: Field,
  public token_id_public: TokenId
) {
  let sender_pubkey = derive_pubkey(sender_privkey);
  let old_commitment = hash(token_id as Field, pubkey_to_field(sender_pubkey), sender_secret);
  assert merkle_verify(ledger.ownership_tree.root(), old_commitment, merkle_path);
  assert hash(sender_secret, old_commitment) == transfer_nullifier;

  // Royalty enforcement: circuit requires valid royalty payment
  let metadata = ledger.nft_metadata[token_id];
  let required_royalty = (sale_price as Uint<128> * metadata.royalty_bps as Uint<128> / 10000) as Uint<64>;

  // Verify royalty was paid to creator
  assert verify_payment_to(royalty_payment_proof, required_royalty, metadata.creator);

  // New ownership commitment
  let new_commitment = hash(token_id as Field, pubkey_to_field(recipient_pubkey), recipient_secret);
  assert new_commitment == new_ownership_commitment;
}
Enter fullscreen mode Exit fullscreen mode

The sale price is a private witness — never on-chain. The royalty amount is derived from it. The circuit proves the royalty was paid without revealing the sale price. Royalties are enforced cryptographically, not by platform policy.


Full Contract Sketch

Bringing it together:

contract PrivateNFTMarketplace {
  // Mint: create token with private ownership
  fn mint(proof: ZKProof, token_id: TokenId, metadata: NFTMetadata, ownership_commitment: Field) {
    assert !ledger.minted_tokens.contains(token_id);
    verify_proof(proof, mint_nft_circuit, [ownership_commitment, token_id]);
    ledger.minted_tokens.insert(token_id);
    ledger.nft_metadata[token_id] = metadata;
    ledger.ownership_tree.insert(ownership_commitment);
  }

  // List: create private listing commitment
  fn create_listing(proof: ZKProof, token_id: TokenId, listing_commitment: Field) {
    assert ledger.minted_tokens.contains(token_id);
    assert !ledger.listings.contains(token_id);
    verify_proof(proof, create_listing_circuit, [listing_commitment, token_id]);
    ledger.listings[token_id] = listing_commitment;
  }

  // Buy: fulfill listing privately
  fn fulfill_listing(
    proof: ZKProof,
    token_id: TokenId,
    transfer_nullifier: Field,
    new_ownership_commitment: Field,
    payment_nullifier: Field
  ) {
    assert ledger.listings.contains(token_id);
    verify_proof(proof, fulfill_listing_circuit, 
      [ledger.listings[token_id], transfer_nullifier, new_ownership_commitment, payment_nullifier]);

    assert !ledger.transfer_nullifiers.contains(transfer_nullifier);
    ledger.transfer_nullifiers.insert(transfer_nullifier);
    ledger.ownership_tree.insert(new_ownership_commitment);
    ledger.listings.remove(token_id);

    // Seller payment handled in proof (private payment circuit)
  }

  // Peer-to-peer transfer without marketplace
  fn transfer(
    proof: ZKProof,
    token_id: TokenId,
    transfer_nullifier: Field,
    new_ownership_commitment: Field
  ) {
    assert !ledger.transfer_nullifiers.contains(transfer_nullifier);
    verify_proof(proof, private_transfer_circuit, 
      [transfer_nullifier, new_ownership_commitment, token_id]);
    ledger.transfer_nullifiers.insert(transfer_nullifier);
    ledger.ownership_tree.insert(new_ownership_commitment);
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementation Considerations

Proving ownership to third parties: If you need to prove to a specific third party that you own an NFT (e.g., to access a token-gated Discord), you generate a "one-time proof" using a nonce tied to the verifier's challenge. This prevents proof replay.

Ownership tree size: Each NFT transfer adds two entries (new commitment; old nullifier). For a marketplace with 100k NFTs and average 10 transfers each, the tree needs 1M leaves at minimum. Plan depth accordingly.

Marketplace fee: The marketplace can charge a fee by requiring a payment proof in the fulfill_listing circuit — similar to the royalty pattern.

Transfer receipts: Buyers need to receive the recipient_secret and confirm it corresponds to the new commitment before a sale is finalized. This requires a two-phase handshake — the buyer generates and shares recipient_secret before the seller submits the transfer transaction.


Summary

A private NFT marketplace on Midnight treats ownership as client-side private state, transfers as ZK proofs of commitment-to-commitment state transitions, and prices as private witnesses never posted on-chain. The sealed bid auction extends this to price discovery without revealing bids until settlement. Royalties are circuit-enforced rather than platform-policy enforced, making circumvention cryptographically impossible rather than merely discouraged. The architecture requires careful attention to state management (clients must retain ownership notes), transfer coordination (recipients must supply their public key and new secret), and tree sizing — but the privacy guarantees are substantially stronger than any EVM-based approach.

Top comments (0)