Inspiration

Every dating app draws you as a pin on a map. That pin exists only because the app holds your exact coordinate. Once a company has your coordinate, it can sell it, leak it, or be forced to hand it over.

We asked: can we build the same product without ever having that data? Not "we promise not to look", but "we cannot draw you because nothing sharper exists".

Midnight's zero-knowledge circuits made that an engineering problem, so we built Halo.

What it does

Halo matches people who are physically near and share interests, without the app ever holding:

  • a precise coordinate
  • an interest vector
  • a date of birth

The home screen is a real map, but nobody on it has a position. Each person is a soft uncertainty area whose radius is the proximity bucket their proof disclosed: closest, nearby, walkable, area. Zooming never sharpens it.

Four circuits do the work:

  • Proximity
    Both devices snap GPS to a 250 m grid and publish only Commit(cell, salt). The circuit proves each cell opens its commitment, the squared distance is in range, and the bucket is correct. Only the bucket is revealed. A nullifier over (commitA, commitB, epoch) limits each pair to one proof per 15 minutes, preventing trilateration attacks.

  • Matching
    A fixed-weight scorer over a 16-dimension interest vector runs entirely on the handset. Any dimension either party marks private is multiplied by zero inside the circuit. You get a proof the model was structurally unable to read it, not a promise it chose not to. Only a coarse 0–4 band is disclosed to avoid vector leakage over repeated queries.

  • Credentials
    Age gating with no ID document touching the app, plus a nullifier that gives Halo exactly one persistent handle per real person (so bans survive reinstalls) while yielding a completely unlinkable handle in any other app.

  • Cross-chain
    Midnight is the privacy engine. Solana is the public record. A 64-byte nullifier and a one-byte band get published as a memo, so a match becomes a portable receipt any Solana program can gate on, without Halo's servers existing and without learning who either person is.

How we built it

  • Frontend: Expo 57 + React Native in Expo Go (no dev build needed).
  • Circuits: Compact under contracts/.
  • Social layer: Firebase Realtime Database for profiles, connections, chat, presence. All private material is kept out of Firebase.

src/state/store.tsx is the only place private material lives: interest vectors, grid cells, salts. The only thing that ever leaves it is a commitment or a proof. Session salts are generated at launch and never persisted, so yesterday's commitments can't be linked to today's.

The UI is a Skia-drawn design system: Geist thin across the whole ramp, a liquid-glass component ported from the web, and six-layer metal buttons that feel pressed rather than animated.

The contract

Publishing a profile reports a single Midnight contract address:

mn_contract1qpzry9x8gf2tvdw0s3jn54khce6mua7l8x9j3k2p5s4m6t2v

Status: simulated deploy. Circuits are written but not yet compiled with compactc, so nothing is on-chain yet. The address is a fixed constant so the receipt, proof sheet, and screenshots all agree.

Challenges

The map was a privacy problem first

Requesting map tiles means sending coordinates to a third party on every pan, the exact disclosure the app exists to avoid. Leaflet needed a WebView. react-native-maps needed an API key and was unreliable in Expo Go.

So cityPlan.ts generates a deterministic pseudo-city (water, parkland, blocks, streets, arterials, tilted off-axis) and Skia draws it with uncertainty areas on top. It depicts nowhere, because the app knows nowhere.

Liquid glass doesn't port

The web original uses an SVG feDisplacementMap. React Native has no equivalent: a native blur view can't hand its backdrop to Skia, and Skia can't sample outside its own canvas.

We rebuilt it from four stacked layers:

  • a genuine expo-blur backdrop
  • a top-lit wash
  • a Skia sweep-gradient specular rim
  • a 1 px glass lip

The rim carries it: a real lens edge is bright where light enters and has a dimmer bounce opposite, which a sweep gradient reproduces and a plain border cannot.

Database rules ate days

Every rule reduces to "does the caller own this wallet?", but a wallet address in a write is a claim the database can't verify. So:

  • auth.uid from anonymous sign-in gets bound to an address exactly once.
  • owners/{uid} exists as a reverse edge so a rule can turn auth.uid into a path segment, enabling "only a participant may read this conversation".

Two memorable bugs:

  1. .read on the users collection also authorised reading the whole collection, dumping every user. Moving .read down to users/$wallet closed that hole.

  2. Accepting a connection must be a single multi-path update(). In rules, root is the state before the write. If you flip status in a separate call first, root no longer says pending, the connection writes get rejected, and the pair lands accepted-but-unconnected, a state nothing recovers from on its own.

Keeping the demo honest

  • The simulated prover re-runs every assertion the Compact circuit makes (openings, range checks, thresholds) and refuses on the same conditions, so the app cannot show a proof for a false claim.
  • Proof.simulated propagates into the UI, which renders a different badge and an explicit notice.
  • With no Midnight mobile wallet, we derive a key in the platform keystore, label it "On-device key," and prefix the address mn_demo1 instead of mn_shield-addr so nobody can mistake it for a funded address.

Accomplishments

  • A map that draws nowhere. We didn't blur a real map, we removed the possibility of one.
  • A privacy model that's structural, not promised. "Unable to read" is different from "didn't read".
  • Database rules with reasoning written into them, including what they don't protect.
  • A "what's live and what isn't" table in the README, written before anyone asked.
  • Runs in Expo Go. Press i or a and it's on your phone.

What we learned

  • Privacy constraints are a design generator. The uncertainty areas, generated city, bucket instead of distance, and coarse band instead of score all exist because we couldn't do the obvious thing. The constrained version is better looking and a better product.
  • Rules languages have real limits, and those limits shape the schema. Firebase rules get one lookup, can't iterate, can't sort two values, and have no trigonometry. connections stores both directions of every edge because a rule can't sort two wallets. owners exists because a rule needs a path segment.
  • Leaking is usually about repeated queries, not single ones. The exact match score is safe once and catastrophic a thousand times. Nearly every "coarse band" decision came from thinking about the thousandth query.
  • Being precise about what's real is a feature. Every hard line we drew (loopback as proof server default, mn_demo1 prefix, simulated badge) made the project easier to explain.

What's next

  • Compile the circuits with compactc and run a real proof server to turn off simulation.
  • Add a real wallet connection. The deeplink transport is written; it waits for a Midnight mobile wallet that supports the scheme.
  • Finish the verification flow: liveness detection into real issuer-signed credentials so the credential circuit proves something a human attested.
  • Solana mainnet attestations and at least one partner app gating on a Halo match, turning the portable receipt from a design argument into something useful.
  • Rate limiting that rules can't do. Declaration-based rules stop one sender spamming one recipient, but can't count across many recipients. That needs to move into the API layer.
Share this project:

Updates

Submission history