ZMSG Protocol v4
The message format ZChat carries in Zcash shielded memos, and over NOSTR
Overview
ZMSG (ZChat Message Protocol) is the wire format for ZChat messages. On the on-chain path a ZMSG line is a pipe-delimited string inside the 512-byte memo field of a Zcash shielded transaction. The same message layer also travels over NOSTR, wrapped as a NIP-17 private DM (NIP-59 gift wrap over NIP-44 encryption), which is how a conversation moves without a transaction per message.
SHIELDED
The ZMSG line rides inside a shielded transaction memo. No server of ours is involved. Each message costs a network fee plus a fixed 1000-zatoshi (0.00001 ZEC) output to a project address, and delivery waits for a block.
TUNNEL
One shielded on-chain KEX anchors the relationship, then messages travel free over NOSTR under the keys the chain anchored.
OPEN
Free and end-to-end encrypted over NOSTR from the first message, with no transaction at all. Unknown senders wait in a Requests inbox.
The transport is chosen per conversation, and a new conversation does not default to the chain. Three ways to carry a message sets out the trade-offs in full.
Message Format
ZMSG|v4|<conv_id>|<type>|<sender_hash>|<payload>
ZMSGProtocol identifier (literal string)v4Protocol version (v2 and v3 are still parsed for backward compatibility)conv_id8-character conversation identifier (A-Z, 0-9), uniquely identifies a conversation threadtypeMessage type marker (INIT, KEX, KEXACK, ADDR). Omitted on a plain message, where the sender hash follows the conversation ID directlysender_hash16 hex characters, first 8 bytes of SHA-256 of sender's Zcash address (12 hex characters in legacy v3 messages, still accepted)payloadType-specific contentMessage Types
| Type | Name | Description | Wire format |
|---|---|---|---|
INIT | Initial Message | First message to start a conversation. Includes sender's full address. | <sender_address>|<message_text> |
(none) | Direct Message | Standard message in an existing conversation. No type marker is sent - the sender hash follows the conversation ID directly. | <message_text> |
KEX | Key Exchange | E2E key exchange. The ECDSA signature binds the public key to the address the sender reports; it does not by itself prove who the peer is. | KEX:<pubkey_b64>:<sig_b64> |
KEXACK | Key Exchange Ack | Sent once a KEX signature verifies, carrying the responder's public key. | KEXACK:<pubkey_b64>:<sig_b64> |
ADDR | Address Change | Signed notice that the sender has moved to a new unified address. | <new_address>|<signature> |
ZREACT | Reaction | Emoji reaction to a specific message. Sent under its own top-level prefix rather than as a ZMSG type. | ZREACT|<target_txid>|<emoji>|<sender_hash> |
ZRCPT | Read Receipt | Read receipt for a specific message. | ZRCPT|<target_txid>|<sender_hash> |
ZREQ | Payment Request | Request a ZEC payment from a contact. | ZREQ|<amount_zatoshi>|<sender_hash>|<reason> |
ZSTAT | Status | User availability status update. | ZSTAT|<status_text>|<sender_hash> |
RPL | Reply (legacy v3) | Quote-reply in the legacy v3 format. Still parsed for backward compatibility, no longer generated. | ZMSG|v3|RPL|<quoted_txid>|<sender_hash>|<reply_text> |
INITInitial MessageFirst message to start a conversation. Includes sender's full address.
<sender_address>|<message_text>(none)Direct MessageStandard message in an existing conversation. No type marker is sent - the sender hash follows the conversation ID directly.
<message_text>KEXKey ExchangeE2E key exchange. The ECDSA signature binds the public key to the address the sender reports; it does not by itself prove who the peer is.
KEX:<pubkey_b64>:<sig_b64>KEXACKKey Exchange AckSent once a KEX signature verifies, carrying the responder's public key.
KEXACK:<pubkey_b64>:<sig_b64>ADDRAddress ChangeSigned notice that the sender has moved to a new unified address.
<new_address>|<signature>ZREACTReactionEmoji reaction to a specific message. Sent under its own top-level prefix rather than as a ZMSG type.
ZREACT|<target_txid>|<emoji>|<sender_hash>ZRCPTRead ReceiptRead receipt for a specific message.
ZRCPT|<target_txid>|<sender_hash>ZREQPayment RequestRequest a ZEC payment from a contact.
ZREQ|<amount_zatoshi>|<sender_hash>|<reason>ZSTATStatusUser availability status update.
ZSTAT|<status_text>|<sender_hash>RPLReply (legacy v3)Quote-reply in the legacy v3 format. Still parsed for backward compatibility, no longer generated.
ZMSG|v3|RPL|<quoted_txid>|<sender_hash>|<reply_text>Several features ride alongside ZMSG under their own prefixes rather than as ZMSG types: ZTL and ZUNLOCK for time-locked messages, ZFILE for attachments, ZEXP for disappearing-message settings, ZMODE for transport-mode changes, and ZMSG:3.0:GROUP: for group chat, including the signed admin controls that gate kicks and key rotation.
Chunking (v4c)
The 512-byte memo limit applies to the on-chain path. When a shielded message exceeds it, the message is split across several memo outputs inside a single shielded transaction, and the recipient's client reassembles the chunks using the sequence numbers (M/N). It remains one transaction and one network fee, and the platform output is paid once; each extra chunk adds a dust output, so the ZIP-317 fee grows with the number of outputs rather than multiplying per message. Messages carried over NOSTR are not subject to the memo limit and are not chunked.
ZMSG|v4c|1/N|<conv_id>|INIT|<sender_address>|<payload_part> ZMSG|v4c|1/N|<conv_id>|<sender_hash>|<payload_part>
ZMSG|v4c|M/N|CONT|<payload_part>
ZMSG|v4c|N/N|CONT|<payload_part>
The first chunk carries the full header (conversation ID, type, sender hash) while continuation chunks use the CONT marker to reduce overhead and maximize payload space.
Encryption Layers
Transport Encryption (shielded memo or NOSTR gift wrap)
On the shielded path the Zcash pool encrypts the entire memo field at the protocol level, and sender, recipient, amount and memo are hidden from third-party observers of the chain. That is a computational guarantee from Zcash's zero-knowledge proofs rather than an unconditional one, and it does not hide that a transaction happened, so timing and size remain a correlation surface. On-chain messages use the Orchard pool today; ZChat moves to Ironwood once the Zcash Android SDK ships support for it.
On the NOSTR path the same ZMSG payload travels as a NIP-17 private DM, gift wrapped per NIP-59 over NIP-44, published under an ephemeral author key. A relay sees an encrypted envelope, the recipient key it is addressed to, and when it arrived. It does not see the sender or the content. We operate one relay; two third-party relays, relay.damus.io and nos.lol, are also used by default and cannot be turned off in the app today.
Application E2E Encryption (negotiated on first contact)
End-to-end encryption using secp256r1 ECDH key agreement, HKDF (RFC 5869) key derivation and AES-256-GCM, over a ZChat ratchet. It applies on every transport, so a Tunnel or Open message is already encrypted by ZChat before a relay ever sees it.
- Key exchange via the
KEXmessage type. The ECDSA signature binds the public key to the address the sender reports, which authenticates the key to that address rather than proving who the peer is - First contact is trust on first use. ZChat shows safety numbers and raises a key-change alert; comparing those numbers out of band is what closes the MITM window
- Group messages:
AES-256-GCMwith a shared group key, distributed per member via ECIES and rotated by epoch on membership change - No per-message forward secrecy. The ratchet keeps a persistent root, so a retained root re-derives earlier message keys. A full double ratchet is designed and not yet shipped
Encryption flow
Plaintext Message
|
v
[AES-256-GCM Encrypt] <-- ECDH P-256 secret via HKDF, over the ZChat ratchet
|
v
ZMSG Payload (encrypted)
|
+--> SHIELDED mode
| |
| v
| [Zcash Shielded Memo] <-- Protocol-level encryption
| |
| v
| Zcash mainnet (opaque to chain observers)
|
+--> TUNNEL / OPEN modes <-- where a new conversation starts
|
v
[NIP-59 gift wrap over NIP-44]
|
v
NOSTR relay (sees an encrypted envelope, the recipient
key and the arrival time; not the sender,
not the content)Conversation ID
Format
8 characters, uppercase alphanumeric (A-Z, 0-9)
Entropy
368≈ 2.8 trillion possible values (~41 bits)
Generation
Randomly generated by the conversation initiator
Scope
Shared between both participants of a conversation
Example
A7K3BX9RConstraints
These are the costs of the on-chain path, and of the shielded handshake that opens a Tunnel conversation. Messages carried over NOSTR pay none of them, and give up the on-chain anchor in exchange.
Source Code
The canonical implementation is in the Android app, MIT licensed and public. It has had no third-party security audit; one has been requested and is pending.
github.com/decentrathai/zchat-androidProtocol parsing
ZMSGProtocol.ktEncryption
E2EEncryption.ktNOSTR transport
Nip17.kt, Nip44Encryption.kt, NostrChatBridge.ktWritten specification
docs/ZMSG_PROTOCOL_SPEC.mdLast revised April 2026, so it covers the on-chain ZMSG layer only and predates the NOSTR transport. Updating it is part of the September publication work.
ZMSG Protocol v4 — ZChat Message Format Specification