Docs
v1

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
NumberTCC-0009
StatusFinal
CategoryMessaging
IdentifierMarker 00 54 52 4D 01 (TRM, version 1); in the sealed text (i/n #id), 🎀 LF tv1:, (re #ref), (fwd) and (react #ref emoji)
Updated2026-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

BytesField
5Marker 00 54 52 4D 01: a zero byte, ASCII TRM, version 1
1Kind: 1 request, 2 accept, 3 message

Request:

BytesField
800The requester's ML-KEM-512 public key
0 to 218Note, UTF-8, readable by anyone

Accept:

BytesField
8The first 8 bytes of the request's transaction id
768ML-KEM-512 ciphertext (capsule)
24Nonce
16 to 218Sealed text: 0 to 202 bytes of UTF-8 and the 16-byte tag

Message:

BytesField
8Chat id
24Nonce
17 to 986Sealed 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.

ValueDerivation
Key pairML-KEM-512 (FIPS 203) from HMAC-SHA512(S, "TENRAI-MESSENGER-v1/KEY") as d β€– z
Messenger IDTRM- and the first 16 bytes of SHA-384("TENRAI-MESSENGER-v1/ID" β€– public key), eight groups of four hex digits
Accept randomnessFirst 32 bytes of HMAC-SHA512(S, "TENRAI-MESSENGER-v1/ACCEPT" β€– request id)
Capsule, shared secretAccepter: Encaps(requester key, accept randomness). Requester: Decaps(private key, capsule)
ContextSHA-384("TENRAI-MESSENGER-v1/CHAT" β€– request id β€– capsule)
Chat idFirst 8 bytes of the context
Requester keyFirst 32 bytes of HMAC-SHA512(shared, "TENRAI-MESSENGER-v1/REQUESTER" β€– context)
Accepter keyFirst 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.

FrameCounts when
AcceptIts transaction spends a coin of the accepter address and its text opens. The request is public, so without that coin anyone could answer it
MessageIt 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:

Text
"(" index "/" count " #" id ")" LF slice
FieldRule
indexThe part's number, 1 to count, decimal without leading zeros
count2 to 10
id6 random letters or digits from A-Z a-z 0-9, one per message
sliceAt 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:

BytesField
1Version 1, or 2 for an answer or a forward
6Message id, random per message
1Index, 0 to count βˆ’ 1
1Count of parts
1Mode
2Frames of the whole message, big-endian
1Version 2 only: flags, bit 0 forwarded, bit 1 answer
8Version 2 only: the answered message's reference, zero without the answer bit
restThe 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.

ModeBit rateFrameBytes per frameFrames per partParts per minute
03,200 bit/s20 ms89432
1 (default)2,400 bit/s20 ms612624
21,600 bit/s40 ms89416
31,400 bit/s40 ms710814
51,200 bit/s40 ms612612

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

CaseWhat the reader does
Same side, kind and idThe parts belong to one message; parts from the other side never join this wallet's messages
An index again, same contentDropped
An index again, other contentThe message is damaged
Count, version, mode, frames or marks disagreeThe message is damaged
A voice part breaks a ruleDamaged, never played
Every index thereComplete: 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

KindTextVoice
Answer(re # ref ), a line feed, then at least one characterVersion 2 voice parts with the answer bit and the reference
Forward(fwd), a line feed, then the original's wordsVersion 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 conventionExample
Base85 of 00 01 02 03 04 05009c61o!
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.

Type to search guides, concepts and reference pages.