Inspiration

Most loyalty programs make users trade privacy for convenience. To earn a free coffee or a discount, customers often give businesses a detailed record of where they were, when they visited, and how often they returned. Over time, that can reveal routines, habits, and patterns about a person's daily life.

We wanted to keep the useful part of loyalty programs — the rewards — without requiring the surveillance behind them.

That became Punchd: a privacy-preserving loyalty card built on Midnight.

What We Built

Punchd is an installable PWA that lets customers collect and redeem loyalty punches without giving merchants a linkable visit history.

Each customer holds a private cryptographic state on their own device. A merchant can verify that a valid punch occurred or that a customer has earned a reward, but cannot determine which previous punches belonged to that same customer.

At redemption, the customer proves only the statement that matters:

$$\text{count} \ge 10$$

without revealing the value of count, the timestamps of previous visits, or the history of punches that led to the reward.

The app includes both sides of the interaction:

  • /card — the customer loyalty card, QR scanner, punch display, and redemption flow
  • /counter — the merchant interface for issuing one-time vouchers and confirming rewards

How We Built It

At the center of Punchd is a simple cryptographic commitment, we have

$$C = H(\text{secret}, \text{count}, \text{salt})$$

where:

  • secret identifies the private card state
  • count stores the number of punches
  • salt ensures that successive versions of the card cannot easily be linked

Each commitment is inserted as a leaf in Midnight's HistoricMerkleTree.

Instead of modifying a card directly, every stamp or redemption follows a spend-and-remint model similar to a UTXO system. The zero-knowledge circuit proves that the current card commitment exists in the Merkle tree, publishes a nullifier so that the old state cannot be reused, and creates a new commitment representing the updated card.

Because each new card uses a fresh salt, the blockchain sees a sequence of unrelated commitments rather than an obvious history belonging to one customer.

Private Stamping

Merchants authorize stamps using one-time voucher secrets.

A voucher is represented publicly as:

$$ V = H(\text{voucherSecret}) $$

The merchant generates the secret and displays it as a QR code. The customer's phone scans it, and the circuit proves knowledge of the preimage of a valid voucher.

Once used, the corresponding voucher is removed from the available set, making each voucher single-use.

This means the same physical interaction that already exists in a loyalty program — showing your phone at the counter — becomes the authorization mechanism.

Private Redemption

When a customer reaches ten punches, the circuit proves:

$$ \text{count} \ge 10 $$

and creates a new card with:

$$ \text{countNew} = \text{countOld} - 10 $$

Any extra punches therefore roll over instead of disappearing.

One important design decision was to submit each punch individually rather than revealing all ten at redemption. Batching the punches would allow the merchant to correlate its own voucher issuances and reconstruct much of the customer's visit history, defeating the purpose of the system.

System Architecture

The customer does not need a crypto wallet.

A relayer attaches the required DUST and submits the proven transaction. The relayer sees only public transaction information — such as commitments, nullifiers, and voucher hashes — rather than the private witness used to create the proof.

The proof server does require the witness, so we treat that as an explicit trust boundary. For the hackathon prototype, it runs locally in Docker behind an HTTPS tunnel so the phone and proof server remain within a user-controlled environment.

Our stack included:

  • Compact 0.31.1 for the Midnight contract
  • Midnight.js 4.1.1 for the API, UI, and relayer
  • React 19 + Vite + Tailwind
  • vite-plugin-pwa for the installable frontend
  • html5-qrcode and qrcode.react
  • idb-keyval for on-device state
  • Express for the relayer

Challenges We Faced

The hardest part was getting the Merkle proof and nullifier logic correct with Midnight's HistoricMerkleTree.

A normal Merkle tree can cause problems when multiple customers interact concurrently: a proof generated against one root may become stale after another user inserts a new leaf. Using the historic tree allows previously valid roots to remain usable, which is important when multiple customers are being served at nearly the same time.

We also found a privacy problem that cryptography alone does not completely solve: timing correlation. If a merchant issues a voucher and sees a corresponding transaction immediately afterward, those two events can potentially be associated even if the on-chain identities themselves are unlinkable.

We did not fully eliminate that problem during the hackathon, so we treated it as a documented limitation rather than hiding it.

Getting the system onto a real phone was another major challenge. Midnight's WASM-based modules had to run inside a Vite PWA, while QR scanning required HTTPS for camera permissions. That meant working through WASM configuration, top-level await support, Node polyfills, HTTPS tunneling, and service-worker cache issues before the actual product experience worked reliably.

What We Learned

The biggest lesson was that privacy does not always require extremely complicated cryptography.

A relatively small set of primitives —

$$\text{hashes} + \text{nullifiers} + \text{Merkle proofs} + \text{zero-knowledge proofs}$$

— can support a surprisingly strong privacy model if the product interaction is designed around them.

We also learned that privacy is not just about hiding data on-chain. Timing, infrastructure, proof generation, and relayers all create potential information leaks. Thinking carefully about the trust boundary became just as important as designing the circuit itself.

Finally, we learned that being explicit about those limitations makes the project stronger. Rather than claiming that every component is completely trustless, Punchd clearly distinguishes what the merchant can see, what the relayer can see, and what the proof server must temporarily know.

What We're Proud Of

Punchd is not a mockup of a privacy system. Every stamp and redemption in the demo uses a real zero-knowledge proof that is verified on-chain.

We also built circuit-level tests covering:

  • merchant authorization
  • single-use vouchers
  • nullifier replay protection
  • forged-card rejection
  • the ten-punch redemption threshold
  • punch rollover
  • claim-code uniqueness
  • stale-root proofs during interleaved use

Most importantly, the product preserves the core experience of a normal loyalty card. Customers still scan, collect punches, and earn rewards — but the merchant learns only what it actually needs to know.

Prove what matters. Reveal nothing else.

What's Next

There are several ways we would extend Punchd beyond the hackathon:

  • On-device proving, eliminating the separate proof-server trust boundary
  • Short-TTL vouchers to reduce photographed-QR replay risks
  • A depth-32 Merkle tree for production-scale usage
  • Multi-merchant loyalty cards
  • Receipt-printed voucher QR codes that tie punches directly to real purchases

The long-term goal is simple: make privacy-preserving loyalty feel just as easy as the loyalty cards people already use today.

Built With

Share this project:

Updates

Submission history