Reference
TCC-0009 Messenger
The TRM frames two wallets chat with, and in their sealed text long texts, Codec 2 voice messages, answers, forwards and reactions.
On this page
| Number | TCC-0009 |
|---|---|
| Status | Final |
| Category | Messaging |
| Identifier | Marker 00 54 52 4D 01 (TRM, version 1); in the sealed text (i/n #id), π€ LF tv1:, (re #ref), (fwd) and (react #ref emoji) |
| Updated | 2026-10-20: long texts, voice messages, answers, forwards and reactions |
Two wallets chat through the payloads of ordinary payments. Every frame is the whole payload of a transaction that pays one atom to the other side, so the other wallet finds it among its own transactions. Consensus does not look inside: the node checks only the payload's size.
Frames
| Bytes | Field |
|---|---|
| 5 | Marker 00 54 52 4D 01: a zero byte, ASCII TRM, version 1 |
| 1 | Kind: 1 request, 2 accept, 3 message |
Request:
| Bytes | Field |
|---|---|
| 800 | The requester's ML-KEM-512 public key |
| 0 to 218 | Note, UTF-8, readable by anyone |
Accept:
| Bytes | Field |
|---|---|
| 8 | The first 8 bytes of the request's transaction id |
| 768 | ML-KEM-512 ciphertext (capsule) |
| 24 | Nonce |
| 16 to 218 | Sealed text: 0 to 202 bytes of UTF-8 and the 16-byte tag |
Message:
| Bytes | Field |
|---|---|
| 8 | Chat id |
| 24 | Nonce |
| 17 to 986 | Sealed text: 1 to 970 bytes of UTF-8 and the 16-byte tag |
At its longest every frame fills the manifest's max_transparent_payload_bytes (1,024 bytes) exactly. The text limits above are what is left after the key, the ids and the encryption. No text payload can be mistaken for a frame: text never starts with a zero byte.
Keys
Each wallet has one messenger key. Its secret S (32 bytes) comes from the wallet root of TCC-0001 along the hardened path m/0x544D'/0x5452'/0', a branch that shares no step with the address keys.
| Value | Derivation |
|---|---|
| Key pair | ML-KEM-512 (FIPS 203) from HMAC-SHA512(S, "TENRAI-MESSENGER-v1/KEY") as d β z |
| Messenger ID | TRM- and the first 16 bytes of SHA-384("TENRAI-MESSENGER-v1/ID" β public key), eight groups of four hex digits |
| Accept randomness | First 32 bytes of HMAC-SHA512(S, "TENRAI-MESSENGER-v1/ACCEPT" β request id) |
| Capsule, shared secret | Accepter: Encaps(requester key, accept randomness). Requester: Decaps(private key, capsule) |
| Context | SHA-384("TENRAI-MESSENGER-v1/CHAT" β request id β capsule) |
| Chat id | First 8 bytes of the context |
| Requester key | First 32 bytes of HMAC-SHA512(shared, "TENRAI-MESSENGER-v1/REQUESTER" β context) |
| Accepter key | First 32 bytes of HMAC-SHA512(shared, "TENRAI-MESSENGER-v1/ACCEPTER" β context) |
Texts are sealed with XChaCha20-Poly1305 under a random nonce; the associated data is the frame up to the nonce. The accept's text uses the accepter key, a message the key of the side that writes it. The accept randomness depends only on S and the request, so every device of the accepting wallet makes the same capsule and a restored wallet finds the chat keys again.
The Messenger ID is a hash of the public key. It tells nothing about the recovery phrase.
Chats
A request names its chat by its transaction id. The owner of its first input is the requester address, the address that received the atom is the accepter address. The requester sends its frames to the accepter address, the accepter to the requester address.
| Frame | Counts when |
|---|---|
| Accept | Its transaction spends a coin of the accepter address and its text opens. The request is public, so without that coin anyone could answer it |
| Message | It opens with the key of the side whose wallet sent the transaction, from any address of that side. Both sides hold both keys, so without this one side could write in the other's name |
A nonce seen twice in a chat is the same frame again and counts once. Until the accept, the requester has no key and writes nothing more; the accepter may write right after its own accept.
Wallets rebuild their chats from their history: requests first, then accepts, then messages. Frames that do not decode or do not open are ignored. A wallet keeps finalized frames apart from the history it trims, so a chat outlasts it; the frames others sent go when it stops reading incoming payloads. The SDK hands them out with Messenger.exportFrames and takes them back with importFrames.
Text
Notes and texts follow the wallets' safe-text rule line by line: no control, direction, format, invisible or private characters, no piles of marks. A zero-width joiner is allowed only between two emoji. Line breaks are allowed, at most three in a row. A text that breaks the rule is not shown.
Display
Wallets show frames only in the messenger, never as a payload; in the activity such a transaction is a messenger entry. The command-line wallet and the daemon mark it with messenger and leave its payload out.
Everything about a chat but the sealed texts is public: who writes to whom, when, how long, and the request's note. The recovery phrase opens every chat of the wallet.
References
Everything from here on lives in the sealed text of messages and keeps to the text rule, so a wallet that reads only the frames shows it as ordinary text. A message's reference is the first 8 bytes of its transaction id as 16 lowercase hex digits; a parted message has the reference of its first part. A message has one once its transaction is signed, and a reader looks references up only in the same chat.
Long texts
A text of at most 970 bytes goes out as one message. A longer one goes out as 2 to 10 parts, each a message of its own with a head line and a slice:
"(" index "/" count " #" id ")" LF slice| Field | Rule |
|---|---|
index | The part's number, 1 to count, decimal without leading zeros |
count | 2 to 10 |
id | 6 random letters or digits from A-Z a-z 0-9, one per message |
slice | At least one character; the slices in order give the original text exactly |
Every part is at most 970 bytes and safe chat text on its own, so the writer breaks between grapheme clusters, preferably after a space or line feed in the last fifth of the room. The joined text must be safe chat text as a whole.
Voice messages
A voice message goes out as parts too. A voice part's text is π€ (U+1F3A4), a line feed, tv1: and the part's bytes in base85:
| Bytes | Field |
|---|---|
| 1 | Version 1, or 2 for an answer or a forward |
| 6 | Message id, random per message |
| 1 | Index, 0 to count β 1 |
| 1 | Count of parts |
| 1 | Mode |
| 2 | Frames of the whole message, big-endian |
| 1 | Version 2 only: flags, bit 0 forwarded, bit 1 answer |
| 8 | Version 2 only: the answered message's reference, zero without the answer bit |
| rest | The part's Codec 2 frames, each in its whole bytes |
The audio is 8 kHz mono speech in Codec 2. A part holds at most 768 bytes, 12 of them head in version 1 and 21 in version 2; every part but the last carries exactly the mode's frames per part, and a voice message lasts at most 60 seconds. Version 2 fits a few frames fewer per part, so a minute takes one part more than below.
| Mode | Bit rate | Frame | Bytes per frame | Frames per part | Parts per minute |
|---|---|---|---|---|---|
| 0 | 3,200 bit/s | 20 ms | 8 | 94 | 32 |
| 1 (default) | 2,400 bit/s | 20 ms | 6 | 126 | 24 |
| 2 | 1,600 bit/s | 40 ms | 8 | 94 | 16 |
| 3 | 1,400 bit/s | 40 ms | 7 | 108 | 14 |
| 5 | 1,200 bit/s | 40 ms | 6 | 126 | 12 |
Any other mode is refused, Codec 2's 1,300 bit/s included: it takes as many bytes as 1,400 bit/s.
Base85 uses the letters 0-9 a-z A-Z .-:+=^!/*?&<>()[]{}@%$# for 0 to 84. Every 4 bytes, big-endian, become 5 letters; a last group of 1 to 3 bytes gives its byte count + 1 letters. A reader refuses any other letter, a length that leaves one letter over, a full group of 2^32 or more, and any spelling that does not come out again when it encodes what it read.
Putting parts together
| Case | What the reader does |
|---|---|
| Same side, kind and id | The parts belong to one message; parts from the other side never join this wallet's messages |
| An index again, same content | Dropped |
| An index again, other content | The message is damaged |
| Count, version, mode, frames or marks disagree | The message is damaged |
| A voice part breaks a rule | Damaged, never played |
| Every index there | Complete: a voice message plays, a text shows as one |
A wallet shows a parted message once, where its first part stands, and counts it once for unread messages and notifications. Every part is its own transaction with one atom to the other side; inputs must be final, so parts that go out side by side need a coin each, and a wallet with too few first splits one coin into several of its own. A full part fills the 1,024-byte payload budget of a work block.
Answers and forwards
| Kind | Text | Voice |
|---|---|---|
| Answer | (re # ref ), a line feed, then at least one character | Version 2 voice parts with the answer bit and the reference |
| Forward | (fwd), a line feed, then the original's words | Version 2 voice parts with the forwarded bit |
A long answer or forward goes out as text parts with the head at the start of the whole text, and a message carries one head at most. The reader shows the answered message above the answer, who wrote it and the start of its words, or "message not available" when the chat has no message with that reference. A forward never names who wrote the original or where it came from.
Reactions
A reaction is a message whose whole text is (react # ref emoji ): one emoji picture, safe text, at most 32 bytes. Without the emoji it takes the side's reaction back. Each side has one reaction per message, the last in chat order; a failed one counts for nothing. Readers show reactions under the message, not as messages, and count them neither as unread nor for notifications.
Text that only looks like a head, such as upper-case digits, a head without text after it or one that does not start the text, is plain text, and so is a reaction with anything but one emoji. The number of parts and their sizes are public, so readers of the chain can tell that a long text, a voice message or a reaction was sent, though not what it says or which message it names. Nothing received runs: voice is decoded only into sound samples, and a reference is looked up only in its own chat.
Vectors
crates/wallet/tests/vectors/messenger-v1.json in the node repository holds two wallets, their Messenger IDs, a request, an accept and a message each way with the texts they carry. The Rust crates, the WASM, the SDK and the web wallet check against it.
| Text convention | Example |
|---|---|
Base85 of 00 01 02 03 04 05 | 009c61o! |
Voice part, mode 1, two frames 00β¦0B, id A1 B2 C3 D4 E5 F6 | π€, LF, tv1:0IM*p!Acx70rAf4009c61o!#m2NH?C |
Reference of 3b1f984bc0099285β¦ | 3b1f984bc0099285 |
| Answer | (re #3b1f984bc0099285) LF Ja, passt! |
| Reaction, taken back | (react #3b1f984bc0099285 π), (react #3b1f984bc0099285) |
The web wallet implements the text conventions, with its Codec 2 module built from the codec2 Rust crate 0.3.1 under the LGPL-2.1; vectors for them in tenrai-test-vectors follow.
