Building a messenger on Midnight: identity on-chain, messages off it

We have been building a private messenger on Midnight for a few months, and this is the first time any of it has been public. It is on Preprod, it is not open to the public yet, and we would rather show the on-chain parts and talk about what was hard than make an announcement.

The idea

Sign up with nothing. No phone number, no email, no wallet, no seed phrase to fund. You pick a username, and you can message someone else by their username. Messages are end to end encrypted and never touch the chain.

The split is the whole design:

On-chain:

proof that you control an identity. A commitment, its status, and the public key your device signs chat messages with. No usernames, no profiles, no message content, nothing that says who you are.

Off-chain:

the messages themselves, end to end encrypted with MLS (RFC 9420), passing through a relay that only ever sees cipher text.

What is actually on Preprod

The identity contract, four circuits, register, prove control, rotate and revoke:

Two real registrations, each one a different person’s device registering itself:

Both are `registerIdentity` calls. What the chain shows is a commitment and a signing key. What it does not show is who either of those people are, and there is nothing in the contract that could.

Things that were harder than expected

  • Nobody should have to hold tokens to sign up.

Registration is a real transaction, and a real transaction costs DUST. So the user signs a transaction that pays no fee, hands it to a separate service, and that service pays out of its own balance and broadcasts it. The person never sees a wallet. This took longer to get right than anything else, and most of that time went on a single wrong assumption: the wallet reported itself ready long before its DUST state had actually synced, so every attempt failed with insufficient funds while the real cause was that it had barely started. The fix was to wait for the right stream, not a faster machine.

  • Usernames without a directory of usernames.

People need to find each other by name, but a server holding a list of everyone’s names is exactly the thing this is supposed to avoid. So the server stores a hash of the username, not the name. Exact lookup works, browsing the list does not. The hash reuses the contract’s own `persistentHash` through a pure circuit, with a different domain prefix and a different arity from the identity commitment, so the two can never collide. A pure circuit turned out to be free to add: no new keys, no redeploy, and we verified that by diffing the compiled output of the existing circuits before and after, then landing a real sponsored transaction on the already-deployed contract.

  • A commitment you cannot reproduce is a commitment you have lost.

The witness values behind an identity were originally random and held only in memory, which meant every registration silently created a throwaway identity and a restart could never prove control of the old one. They are now derived from the device seed, so the same seed always reproduces the same commitment. The test that convinced us was not a unit test: it was registering twice from the same device and watching the contract reject the second attempt with its own “identity already registered” assertion. A generic failure would have proved nothing. That specific one proved the commitment reproduced byte for byte.

  • Messages that arrive while you are away.

MLS state lived in memory, so closing the app threw away the keys needed to read anything sent afterwards. Keeping it means the relay can hold a message for someone who is offline and hand it over when they return, then delete it. Getting that wrong is invisible in tests and obvious in practice: our first version handed the stored messages over before the client had finished loading its keys, and separately confirmed receipt before the message had actually been handled, so the server deleted messages that never arrived. Both only showed up when we ran it against the deployed build rather than a test harness.

What we are not claiming

It is on Preprod, not mainnet. The relay has no authentication yet, so anyone who learns a conversation id can join it, and that is the next real piece of work. A storing relay learns timing and pairing even though it cannot read anything, which is the ordinary cost of offline delivery and worth saying out loud rather than glossing over. Proving happens against a prover the user does not run, which is a genuine trust boundary and is documented as one rather than described as fully local.

Where it goes

Native iOS and Android are the real products. The web build exists to find bugs, and it has been doing that job well. Push notifications belong with the native apps, since the parts underneath, held messages and persistent keys, are already in place.

Happy to answer questions about any of it.