COMPAX

DOCUMENTATION

COMPAX,
DOCUMENTED

Here is the documentation, and it is one page: nine sections, in the order they are usually needed. Start here is what actually runs — three processes, two stores, and MLS underneath. Bots is the longest of them: a gateway that speaks Telegram’s Bot API against an end-to-end encrypted messenger, method by method, and the widgets Telegram has no equivalent for. Run your own server goes from one compose file to a second machine, and says what is stored and what is reported while it runs. How the server works and Limitations are the decisions visible from outside: one order per room, a log that is a buffer, and what it refuses under load. Federation is more than one operator — who reaches whom, and over which protocol. The last three are the wallet’s side of the app: the group wallet, the dApp browser whose pages can ask it to sign, and WalletConnect for a site outside the app.

MLS · RFC 9420the encryption, and a standard one Rust · Postgres · Redisthe delivery service and its gateway Flutterone app, every platform

GETTING STARTED

Start here

  • RedisRedis
  • Telegram Bot APITelegram Bot API
  • RustRust

What runs is three processes and two stores. The delivery service orders messages and hands them out; it keeps rooms and their logs in Postgres and shares presence, rate buckets and its live channels through Redis, so more than one instance of it is a deployment decision rather than a rewrite. Beside it the Bot API gateway speaks Telegram’s surface to whatever bots you run.

The encryption is MLS, RFC 9420 — a standard rather than something of ours, with forward secrecy and post-compromise security, and it is what makes a group conversation cost one message rather than one per member. The service never holds a key that opens anything: it orders ciphertext, and the order is all it contributes. Both it and the gateway are Rust; the app is Flutter, one codebase for the phones, the desktops and the web.

A deployment is a container each and a Postgres and a Redis to point them at — the compose file is in the repository and builds the image itself, along with the migrations the service runs against its own database on start.

On its first run the gateway mints @botfather and prints its identity to the log. That account is where bots come from, and it is an ordinary bot on the same surface as yours — it sends messages, hangs keyboards, answers taps and edits its own messages through the same code path. Whatever is broken about buttons is broken for it too, which is the point of building it that way.

LOOPBACK ONLY

The gateway serves plaintext and binds 127.0.0.1; there is no TLS here. Nothing in a URL is a secret any more — a request proves itself with a signature, not with what it carries in the path — but plaintext still shows every message a bot sends and receives to anything on the wire. Putting it on a public interface is a deployment decision with a security review attached, not a flag to flip.

BOTS

Bots

  • Telegram Bot APITelegram Bot API
  • PythonPython
  • Node.jsNode.js
  • RustRust
  • MLSMLS

The strong reading of “Telegram compatible” is the only one that is testable: a bot written with python-telegram-bot, aiogram, telegraf or grammY runs against Compax with the base URL changed and nothing else. That is what the gateway is built to be — not a lookalike of Telegram’s buttons, but the wire protocol those libraries already speak.

BRINGING A BOT ACROSS
A Telegram bot, moved to CompaxA bot written for Telegram runs on Compax with its base URL changed and nothing else. Sending, editing, updates, webhooks, inline keyboards, callbacks, commands, entities and files all behave as they do on Telegram. Two rows are drawn in the accent because they are the two differences: authorisation is a signature per request rather than a token in the path, and native widgets and a group wallet exist here and have no Telegram equivalent.SENDMESSAGE, EDITMESSAGETEXTGETUPDATES AND WEBHOOKSINLINE KEYBOARDS, CALLBACKSCOMMANDS, ENTITIES, FILESAUTHORISATION: A SIGNATUREADDED: WIDGETS AND A WALLETYOUR TELEGRAM BOTTHE SAME BOT,RUNNING ON COMPAX

Change the base URL. That is the move — the surface on this side is Telegram’s, to the letter, because the test of “compatible” that means anything is that somebody else’s library runs against it unmodified. Two things are ours, and both are marked: the token in the URL becomes a signature on every request, and a group here can be handed things a chat could not hold.

A bot is a member of the group

On Telegram a bot is an account on Telegram’s servers, and the Bot API is a view onto a database somebody else owns. Here the server holds ciphertext and an epoch counter; there is no plaintext anywhere but on a member’s device. So a bot has its own MLS device and its own identity key, and it joins the group as a member . There is no other place plaintext exists for it to read.

Three consequences, all of them visible in the app rather than hidden by it:

  • Adding a bot is adding a member. It shows up in the member list, because MLS owns the roster and the app asks rather than keeping its own copy.
  • A bot reads every message in the group, always. Telegram has privacy mode; we cannot implement it, because the bot holds the group’s keys and has decrypted the message before any rule could apply. getMe reports can_read_all_group_messages: true and that is a fact about the architecture, not a setting.
  • Removing a bot is a real MLS removal, which rotates keys forward. “Kicked but still holding the keys” is not a state that gets presented as removed.
A BOT IS A MEMBER
A bot is a member of the groupThe group drawn as rings of members and their devices. The bot is one of the dots — it holds a leaf, an identity key and a device of its own — and the line running out of the circle to your backend is the only thing about it that is unusual.A GROUP, ITS MEMBERSAND THEIR DEVICESTHE GROUPYOUR BACKENDSPEAKS FOR THIS ONE

It holds a leaf in the tree, an identity key and a device of its own. The line leaving the circle is the only unusual thing about it.

Making a bot

Find @botfather in the app’s chat search and say /newbot. It asks for a name, a username ending in bot — matching Telegram’s own rule, so bot code doing username.endswith("bot") is right — and then for the bot’s key, which your app derives from your own recovery phrase. The gateway never sees that phrase and cannot make the key itself. It starts the bot’s device and tells you what the bot is:

Four messages, and a bot exists
9:41
@botfatherbot
/newbot10:14 ✓✓
@botfatherA name, and a username ending in bot?10:14
Orders · @orders_bot10:14 ✓✓
@botfatherDone. @orders_bot is 9f2c4d19…10:15
+Write a message…🎤

@botfather is an ordinary bot on the same surface as the one it is making — it sends messages and reads replies, and nothing else. The key at the end is the bot's public half; the private one was derived on your device from your own phrase, and the gateway never saw either.

WHAT @BOTFATHER ANSWERS
@orders_bot is 9f2c4d19b7e05a3c9d61f8a24b7e0c93d15641ac8e37b2f0a94c6d1e5b83702f.
└─ the bot's public key — thirty-two bytes, hex

That is a public key, not a secret. It names the bot in a URL and proves nothing on its own, so a paste of that line, a proxy log or a browser history hands nobody anything. What proves a request is a signature, made with a device your own application issues for the backend — see pointing a backend at it.

Registering is the same rule one step earlier. The device your application sends carries an attestation: the bot’s identity key signing this device may speak for me. @botfather verifies that signature before it registers anything, so claiming a key you do not hold fails here rather than later — you would have to forge a signature by the identity you are claiming to be. The record’s own device key is checked against the attested one too, which is what makes the two halves one device rather than two.

There is no token here and nothing that stands in for one. The shape this replaced was Telegram’s — 146492815:AAHq…, an id and thirty-five characters — and it was a password in a URL path: whoever held it could speak as the bot in every group the bot was in, and it travelled wherever a backend’s configuration travelled. A signature cannot be replayed, cannot be read out of a path, and can be taken back.

  • /newbot, in the app @botfather refuses to do this in a group and says why. Nothing it prints is a secret any more, but making a bot is still an act with an owner, and a group log is a poor place to settle who that is.
  • Your app makes the key, not the gateway A bot is derived from your recovery phrase at a random index, so the gateway holds a public key and a number — neither of which reproduces the bot. It used to generate a phrase per bot and keep it in the clear, which meant whoever read that file was every bot in it, and losing the file lost them for good. Now the phrase you already have brings any of them back.
  • Add the bot to a conversation Creating a bot is not adding it to anything. Joining a group is an MLS commit, and only a member can make one — so a member adds it, from the group’s member list. Nothing can let itself in.
  • Issue a device, and run your backend against that Below. Your application exports a device for the backend; the bot’s identity stays in the application, which is what makes a leaked backend key a revocation rather than a funeral. /mybots stops this gateway serving with a device, and your app issues another at a raised version — the bot stays the same member of the same chats. /delete takes the bot off this gateway — its record, its device, its name — and its leaf stays in every group it was in, silent, until somebody removes it there. What /delete no longer does is end the bot: it is derived from your phrase, so the same key comes back at the same index. That used to be a one-way door, because the only copy of a bot’s identity was the one on the gateway’s disk.

The gateway’s own registry lists every bot it knows: the public key, and the device it is being served with. Both are public, so reading it is a status check rather than a way to get at anything — which is new. That file used to hold a working credential for every bot on the machine, and twenty-four words that reproduced each of them.

WHERE A BOT COMES FROM
How a bot’s key is madeA bot is derived from your own recovery phrase at an index. Your application makes the key and attests a device for the backend; the gateway is given the public half and serves with that device, and can be told to stop.YOUR RECOVERYPHRASEAN INDEXTHE BOT’SIDENTITY KEYA DEVICE,ATTESTEDTHE GATEWAYSERVES IT

The gateway holds a public key and a number, neither of which reproduces the bot. Deleting one is no longer a one-way door.

Pointing a backend at it

Every method lives at http://127.0.0.1:8081/bot<key>/<method>, where <key> is the bot’s public key rather than a credential. GET and POST both work, parameters arrive as JSON, form-encoded or query string, and the answer is the usual {"ok": true, "result": …} envelope.

What a request must also carry is a signature: an x-chat-relay header covering the method, the path and query, the body, a timestamp and a nonce, made with the device key your app exported. It is the same envelope this deployment’s servers use to talk to each other, so there is one construction to get right rather than two.

THE KEY IN THE PATH IS NOT HOW YOU GET IN

Naming a public key authenticates nobody — it is an address, and anyone can write one down. What gets you in is holding the private key, and the only thing that demonstrates that is the signature.

So the gateway does not trust the key the envelope names. It looks up the device it is serving that bot with, refuses the request if the envelope names any other key, and only then verifies the signature against it. Naming somebody else’s bot and signing with your own key produces a perfectly valid signature and a 404.

Everything the signature covers is compared with what actually arrived: a signature over getMe does not authorise a sendMessage, one sealed for another deployment is refused by this one, one older or newer than a minute is refused, and a nonce is spent once — so a captured request cannot be sent twice.

Then why is the key in the path at all? For authentication it is redundant, and that is worth saying rather than leaving to be worked out: the envelope names the device, so the gateway could find the bot from the signature alone. The segment is there because the URL shape is Telegram’s — every library builds /bot<something>/<method> out of its token argument, and something has to go in it. What goes in it is then a choice between a secret and a name, and the name is the one that can be logged. It earns its keep a third way: the request has to state which bot it is for, and the signature is checked against that — so addressing one bot while signing as another is refused rather than quietly served as the signer.

That is the one place a Telegram bot library needs help: it will build the path and the body correctly and will not sign anything. A few lines of middleware in whatever HTTP client it uses is the whole adaptation — and in exchange, nothing your bot holds can be read out of a URL, a proxy log or a paste.

Two things, and only one of them is a secret

A backend is given one file — what your application exported — and everything comes out of it:

  • bot — the public key. It goes in the path, and that is all it does. Nothing is ever signed with it: a public key cannot sign, which is what makes it safe to put in a URL.
  • record.signing_seed — the device’s private key, thirty-two bytes. Every request is signed with this, and it never leaves the backend. This is the secret; the file holding it is the thing to keep.
  • record.attestation — the bot’s identity key saying this device may speak for me. That is what the gateway checked when the device was installed.
THE FILE, AND WHAT IS IN IT
{
  "bot": "9f2c4d19…",         // public — this goes in the path
  "base_url": "http://127.0.0.1:8081",
  "record": {
    "device_id": "4b1f…",
    "signing_seed": "a3f0…",  // PRIVATE — this signs every request
    "attestation": { …the identity's signature over this device… }
  }
}

# Ed25519: the seed is the private key, and the key in the path is its
# public half. The gateway holds only the public half and checks the
# signature against it — so possession of the private key is the proof,
# and it is the one thing that never travels.

Signing a request, in full

Twenty lines and no dependencies. This is the whole of authorising: lay out the bytes, sign them, put the result in a header.

WHAT THE SIGNATURE COVERS
The bytes a relay signature covers, in orderThe eight fields in the order the bytes go in, because the order is the format. Each of the three variable fields is preceded by a four-byte big-endian length, drawn here in the accent colour, and the bar is how many bytes the field takes.chat-relay-envelope-v122 BYTES — A LITERAL, SIGNED FIRSTdevice_pubkey32 BYTES — THE PUBLIC HALF OF THE SIGNING KEYhome3 BYTES — READ FROM THE ATTESTATIONu32be(len)4 BYTES — HOW LONG THE NEXT FIELD ISmethodVARIABLE — POSTu32be(len)4 BYTESpathVARIABLE — THE QUERY INCLUDEDu32be(len)4 BYTESbodyVARIABLE — THE EXACT BYTES, NOT A HASH OF THEMissued_at8 BYTES — UNIX SECONDS, BIG-ENDIANnonce16 BYTES — RANDOM, AND SINGLE-USE

The order is the format. The three lengths are the point — a four-byte count fused to the front of each field whose size the format does not fix.

Why the three prefixes. Without them a longer path and a shorter body lay out identically to a shorter path and a longer one — so one signed request could be read as a different request, with the signature still checking out. The length in front of each variable field is what makes the concatenation readable in exactly one way.

  • The body is the exact bytes you will send. Not a hash of them, and not a re-serialisation — a JSON library that reorders a key or changes a space produces a signature over something other than what arrives.
  • The nonce is what stops a replay. The signature proves who wrote the request and nothing about when; the nonce is single-use and issued_at is checked against a skew window of a minute, so the same signed bytes cannot be presented twice.
  • The literal is signed first, on purpose. A signature made for this envelope cannot be replayed as a signature for another protocol that happens to lay its fields out the same way.
THE HEADER, IN FULL
import { createPrivateKey, sign, randomBytes } from "node:crypto";

// An Ed25519 seed is a raw 32 bytes; node wants PKCS#8, which is that
// seed behind a sixteen-byte header that never changes.
const PKCS8 = Buffer.from("302e020100300506032b657004220420", "hex");
const keyFromSeed = (seed) =>
  createPrivateKey({ key: Buffer.concat([PKCS8, seed]), format: "der", type: "pkcs8" });

const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n); return b; };
const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(n)); return b; };

function relayHeader({ seed, publicKey, home, method, path, body }) {
  const issuedAt = Math.floor(Date.now() / 1000);
  const nonce = randomBytes(16);

  const signed = Buffer.concat([
    Buffer.from("chat-relay-envelope-v1"),
    publicKey, Buffer.from(home, "hex"),
    u32(Buffer.byteLength(method)), Buffer.from(method),
    u32(Buffer.byteLength(path)), Buffer.from(path),
    u32(body.length), body,
    u64(issuedAt), nonce,
  ]);

  const signature = sign(null, signed, keyFromSeed(seed));  // the private key

  return Buffer.from(JSON.stringify({
    device_pubkey: [...publicKey],                          // an array of numbers, not hex
    home, method, path,
    issued_at: issuedAt,
    nonce: [...nonce],                                      // likewise
    signature: signature.toString("hex"),                   // this one is hex
  })).toString("base64url");                                // no padding
}
THE HEADER, IN FULL
import base64, json, os, struct, time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

def relay_header(seed, public_key, home, method, path, body):
    issued_at, nonce = int(time.time()), os.urandom(16)
    signed = (b"chat-relay-envelope-v1" + public_key + bytes.fromhex(home)
        + struct.pack(">I", len(method)) + method
        + struct.pack(">I", len(path)) + path
        + struct.pack(">I", len(body)) + body
        + struct.pack(">Q", issued_at) + nonce)

    signature = Ed25519PrivateKey.from_private_bytes(seed).sign(signed)

    envelope = json.dumps({
        "device_pubkey": list(public_key), "home": home,
        "method": method.decode(), "path": path.decode(),
        "issued_at": issued_at, "nonce": list(nonce),
        "signature": signature.hex(),
    }).encode()
    return base64.urlsafe_b64encode(envelope).rstrip(b"=").decode()

And used — the body is rendered once and then both signed and sent. Rendering it twice is how a signature comes to cover something the request does not contain, and the refusal that follows names the signature rather than the line that caused it.

ONE CALL
const export_ = JSON.parse(fs.readFileSync(process.env.BOT_DEVICE, "utf8"));
const seed = Buffer.from(export_.record.signing_seed, "hex");
const publicKey = createPublicKey(keyFromSeed(seed))
  .export({ type: "spki", format: "der" }).subarray(12);
const home = export_.record.attestation.attestation.home;

const path = `/bot${export_.bot}/sendMessage`;
const body = Buffer.from(JSON.stringify({ chat_id: -1001, text: "Hello." }));

await fetch(export_.base_url + path, {
  method: "POST",
  headers: {
    "x-chat-relay": relayHeader({ seed, publicKey, home, method: "POST", path, body }),
    "content-type": "application/json",
  },
  body,
});
ONE CALL
export = json.load(open(os.environ["BOT_DEVICE"]))
seed = bytes.fromhex(export["record"]["signing_seed"])
public_key = Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes(
    serialization.Encoding.Raw, serialization.PublicFormat.Raw)
home = export["record"]["attestation"]["attestation"]["home"]

path = f"/bot{export['bot']}/sendMessage"
body = json.dumps({"chat_id": -1001, "text": "Hello."}).encode()

requests.post(
    export["base_url"] + path,
    headers={
        "x-chat-relay": relay_header(seed, public_key, home, "POST", path, body),
        "content-type": "application/json",
    },
    data=body,                              # the same bytes that were signed
)
SIGNING A REQUEST
from telegram.ext import ApplicationBuilder, CommandHandler
from nacl.signing import SigningKey                                   # any Ed25519 will do

export = json.load(open(os.environ["BOT_DEVICE"]))
device = SigningKey(bytes.fromhex(export["record"]["signing_seed"]))  # the private key

app = (
    ApplicationBuilder()
    .token(export["bot"])             # the PUBLIC key — it only
                                      # builds the path
    .base_url("http://127.0.0.1:8081/bot")
    .request(SigningRequest(device))  # the private key signs here
    .build()
)

async def start(update, ctx):
    await update.message.reply_text("Hello from Compax.")

app.add_handler(CommandHandler("start", start))
app.run_polling()
SIGNING A REQUEST
from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer

session = AiohttpSession(
    api=TelegramAPIServer.from_base("http://127.0.0.1:8081")
)
# export["bot"] is public and addresses the bot; the seal below is what
# proves anything, and it is made with export["record"]["signing_seed"].
bot = Bot(token=export["bot"], session=session)
# session.middleware(…) is where the x-chat-relay header goes on
dp = Dispatcher()
SIGNING A REQUEST
// The public key goes where the token went — it addresses, it does not
// authenticate. signing() below holds record.signing_seed and seals each
// request with it.
const bot = new Bot(export.bot, {
  client: {
    apiRoot: "http://127.0.0.1:8081",
    fetch: signing(device),   // adds x-chat-relay to each request
  },
});

// telegraf
const bot = new Telegraf(export.bot, {
  telegram: { apiRoot: "http://127.0.0.1:8081", agent: signing(device) },
});
SIGNING A REQUEST
BASE=http://127.0.0.1:8081/bot$BOT_KEY

# Every request needs an Ed25519 signature over the method, the path,
# the body, a timestamp and a nonce — so curl on its own cannot make
# one, and an unsigned request is answered 404 like any other stranger.
# $SIGN below stands for whatever produces that header for you.

curl -s -X POST $BASE/sendMessage \
  -H "x-chat-relay: $(SIGN POST /bot$BOT_KEY/sendMessage "$BODY")" \
  -H 'content-type: application/json' \
  -d "$BODY"

# the header is base64url of this, signed with the exported device key:
# {"device_pubkey":…,"home":…,"method":"POST","path":"/bot…/sendMessage",
#  "issued_at":1731000000,"nonce":…,"signature":…}
SIGNING A REQUEST
// crates/botapi/examples/demo_bot.rs — reqwest and serde_json
// against a base URL, plus the one helper that signs.

// What your application exported: which bot this is, where it lives,
// and the device that speaks for it. No identity key is in the file.
let export: signing::Export =
    serde_json::from_slice(&std::fs::read(std::env::var("BOT_DEVICE")?)?)?;

// Rendered once, then both signed and sent. Rendering twice is how a
// signature ends up covering something the request does not contain.
let path = format!("/bot{}/{method}", hex::encode(export.bot));
let body = serde_json::to_vec(&params)?;
let signed = signing::header(&device, home, "POST", &path, &body);

let answered: Value = http
    .post(format!("{base}{path}"))
    .header(protocol::relay::RELAY_HEADER, signed)
    .header(CONTENT_TYPE, "application/json")
    .body(body)
    .send().await?.json().await?;

THE CHECK WORTH DOING ONCE

Point the same binary at https://api.telegram.org with a real Telegram token, and take the signing header off. If the methods, the parameters and the answers stop matching, the compatibility claim on this page is wrong — and that is the one test that can falsify it. The demo bot is written to be run both ways for exactly that reason.

What the signature costs is worth saying plainly: the shapes are Telegram’s, but a library can no longer be pointed here by changing one line. It needs a few lines of middleware in whatever HTTP client it uses, and today the only worked example of that middleware is in Rust.

What is not claimed yet: a conformance suite generated from the Bot API schema, and the library example bots running in CI. Both are written down as work, not as done. The claim today is “the methods in the table below, tested”, not a version number.

WHAT CROSSES THE WIRE
What crosses the wire to the gatewayYour backend speaks the Bot API to the gateway: the methods that draw something in the group, the updates that come back, and the signature that proves the request. There is no token in any of it.SENDMESSAGE, SENDPHOTO, …EDITMESSAGETEXTINLINE KEYBOARDSNATIVE WIDGETSGETUPDATES, WEBHOOKANSWERCALLBACKQUERYX-CHAT-RELAY SIGNATUREYOUR BACKENDWHAT THEGROUP SEESWHAT COMESBACKWHAT PROVESIT

The Bot API, unchanged, plus one header. What is missing is the token: nothing in the path is a secret, and the signature is what gets you in.

The update loop

Poll with getUpdates. An update is confirmed by the next call’s offset, the way Telegram does it: limit defaults to 100 and is clamped there, timeout is a long poll of up to 300 seconds, and allowed_updates filters.

JSON — a message arriving
{
  "update_id": 41,
  "message": {
    "message_id": 17,                  // the group log's own seq
    "date": 1755993600,
    "chat": { "id": -1001, "type": "group", "title": "Ridge Trip" },
    "from": { "id": 5501, "is_bot": false, "first_name": "Ada" },
    "text": "/ask something",
    "entities": [{ "type": "bot_command", "offset": 0, "length": 4 }]
  }
}

Those entities are not decoration. CommandHandler in python-telegram-bot and CommandStart in aiogram never look at the text — they find a bot_command entity at offset 0 and take the command from the span. The gateway computes them in UTF-16 code units, because that is the unit every library slices with, and one emoji before a command shifts everything after it by two.

Identifiers

Every Bot API library types chat.id and from.id as 64-bit integers, not opaque strings, and real bot code branches on the sign. Our identifiers are a 32-byte identity hex and a UUID, so the gateway keeps a persistent registry that mints them once and never reuses one:

Bot API fieldOur thingRule
chat.idgroup id (UUID)minted i64, negative for groups and positive for a 1:1 chat
from.ididentity hexminted positive i64, stable forever
message_idseq in the group logdirect — already an int, already per-chat, already agreed by every member
update_idgateway countermonotonic per bot

Keyboards and callbacks

An inline keyboard is Telegram’s own JSON, passed through verbatim rather than re-modelled — a round trip that loses a field is a conformance bug we want a test to see, and a second schema hides those by construction. Hang it on a message, and a tap comes back as a callback_query.

A tap, and the round trip it is
9:41
Office coffee4 members
@barista_botFlat white or latte?Latte — ready in about four minutes.11:02
Flat whiteLatte
+Write a message…🎤
Ordering…

WHAT THE SERVER SEES

  1. POST /sendMessage{"text": "Flat white or latte?", "reply_markup": {"inline_keyboard": [[{"text": "Latte", "callback_data": "cup:latte"}]]}}
  2. callback_query{"id": "cb-7", "data": "cup:latte", "from": {"id": 5501}}The tap. Nothing has changed on the screen yet.
  3. POST /answerCallbackQuery{"callback_query_id": "cb-7"}This is what stops the spinner — nothing else does.
  4. POST /editMessageText{"message_id": 18, "text": "Latte — ready in about four minutes."}No reply_markup, so the keyboard goes.

Watch what does not happen. The key goes down and stays down — nothing on this screen knows what the answer is, because the answer is the bot's. The spinner stops when answerCallbackQuery lands, and the message is redrawn in place: same bubble, new words, no keyboard. A second press is not refused, it is unavailable.

1 · THE BOT OFFERS
POST /bot$KEY/sendMessage
{
  "chat_id": -1001,
  "text": "Flat white or latte?",
  "reply_markup": { "inline_keyboard": [[
      { "text": "Flat white", "callback_data": "cup:flat" },
      { "text": "Latte", "callback_data": "cup:latte" }
  ]] }
}

Somebody presses one. Nothing on their screen changes — the app has sent the string and is waiting, which is the one thing this exchange is about. What the bot receives is not a request it made:

2 · WHAT ARRIVES, FROM getUpdates
{ "update_id": 42, "callback_query": {
    "id": "cb-7", "data": "cup:latte",
    "from": { "id": 5501, "first_name": "Ada" },
    "message": { "message_id": 18, "chat": { "id": -1001 } } } }

Two calls answer it. The first stops the spinner and is the only thing that can; the second edits the message that is already in the conversation, so the keyboard cannot be pressed a second time.

3 · THE BOT ANSWERS
POST /bot$KEY/answerCallbackQuery
{ "callback_query_id": "cb-7", "text": "Ordering…", "show_alert": false }

POST /bot$KEY/editMessageText
{ "chat_id": -1001, "message_id": 18, "text": "Latte — ready in about four minutes." }

Supported on an InlineKeyboardButton today: text, callback_data, url and copy_text. web_app, switch_inline_query* and login_url are later stages; pay and callback_game are refused — see below. Reply keyboards, ForceReply and ReplyKeyboardRemove are drawn by the app; the gateway does not yet turn a keyboard tap or a picked poll back into an update.

CALLBACK DATA IS NOT PRIVATE IN A GROUP

On Telegram, tapping an inline button sends callback_data to the bot and to nobody else. Here the tap is a control message on the group log, encrypted to the group — so every member sees it.

There is no fix inside the group: the bot is a member, and a message only the bot could read would need a second key exchange with the bot alone. In a 1:1 chat with a bot there is no third party and the semantics are identical to Telegram. In a group, taps are visible to members — exactly the property polls already have. Do not put a secret in a button.

Native widgets

A keyboard is rows of buttons, so every question that is not which of these buttons becomes a conversation: one message per value, and no way at all to collect a set, a quantity or a day in place. A widget is the answer to that: a card the app draws from JSON the bot sends, in the same message, with the same callback channel underneath.

Every control there is, what each one sends back, and a constructor that writes the JSON and draws the card as you turn its knobs, are on a page of their own. They were four hundred lines in the middle of this one, which is a reference section standing where a tutorial should be.

The API, method by method

Generated from nothing — this is read off crates/botapi/src/api.rs, and a method not named here answers 404.

MethodStatusNotes
getMetakes nothinganswers{"ok": true, "result": User}Reports can_read_all_group_messages: true, always — a bot here is a member of the group and decrypts everything in it.worksreports can_read_all_group_messages: true, always
getUpdatesoffsetthe first update to return; confirming everything before itlimitup to 100, and 100 if you do not saytimeoutseconds to hold the request open, up to 300; 0 returns at onceallowed_updatesan array of the kinds you wantanswers{"ok": true, "result": Update[]}Long polling. Advance offset before you handle, not after, or a handler that panics answers the same message forever.worksoffset, limit (≤100), timeout (≤300s), allowed_updates
sendMessagechat_idrequiredwhich conversationtextrequiredthe message, and the fallback for a client that cannot draw a widgetreply_markupan inline keyboard, Telegram's own JSON, passed through verbatimreply_to_message_idthe message this answerswidgeta native card — see the widget sectionanswers{"ok": true, "result": Message}workstext, reply_markup, reply_to_message_id, widget
editMessageTextchat_idrequiredmessage_idrequiredtextrequiredthe new textreply_markupthe new keyboard; leaving it out takes the buttons awayanswers{"ok": true, "result": Message}Last one wins, and only from the sender of that message.workslast one wins, and only from the sender of that message
editMessageReplyMarkupchat_idrequiredmessage_idrequiredreply_markupno markup removes the buttons; an empty keyboard is a different thinganswers{"ok": true, "result": Message}worksno markup removes the buttons; an empty keyboard is a different thing
editMessageWidgetchat_idrequiredmessage_idrequiredwidgetthe next state of the card; no widget restores the textanswers{"ok": true, "result": true}Ours, not Telegram’s. This is how a card advances without sending a second message.worksours, not Telegram’s; no widget restores the text
answerCallbackQuerycallback_query_idrequiredthe id from the updatetextthe toast, at most 200 characters — longer is refused, not truncatedshow_alerta dialog instead of a toast, for what must not be missedurlhttps only, at most 2048 characters; anything else is refusedanswers{"ok": true, "result": true}The control is drawn waiting until this lands, so answer first and redraw after.workstext, show_alert, url; answers true, not an object
getChatchat_idrequiredanswers{"ok": true, "result": Chat}workstype is private or group, never supergroup
setMyCommandscommandsrequiredthe listscopedefault, all private chats, a chat, a memberlanguage_codethe language this list is foranswers{"ok": true, "result": true}Refused with a reason when there are too many commands, or too many scopes and languages of them.
getMyCommandsscopelanguage_codeanswers{"ok": true, "result": BotCommand[]}
deleteMyCommandsscopelanguage_codeanswers{"ok": true, "result": true}
worksall seven scopes, with Telegram’s most-specific-first resolution
setChatMenuButtonchat_idomit for the defaultmenu_buttonanswers{"ok": true, "result": true}
getChatMenuButtonchat_idomit for the defaultanswers{"ok": true, "result": MenuButton}
worksstored and handed back
setMyName / Description
setMyShortDescriptionshort_descriptionlanguage_codeanswers{"ok": true, "result": true}
…and their getters
workscalled at startup by both Python libraries, so a 404 here would stop a bot before its first message
deleteWebhooktakes nothinganswers{"ok": true, "result": true}There is no webhook. Answered so a library’s first call does not fail.
getWebhookInfotakes nothinganswers{"ok": true, "result": an object saying no webhook is set}
answeredthere is genuinely no webhook, and nearly every library asks before its first poll
setWebhooktakes nothinganswers{"ok": true, "result": 400}Not built yet — poll with getUpdates.400not built yet — poll with getUpdates
sendInvoice
answerPreCheckoutQuery
createInvoiceLink
refusedTelegram Payments assumes custody by Telegram. We have on-chain invoices and multisig group wallets, which is a better and a different thing
sendGame
setGameScore
getGameHighScores
refusedno games platform, and pretending otherwise breaks worse than a clean error
banChatMember
restrictChatMember
promoteChatMember
…and the rest
refusedMLS groups are flat: there is no administration model to grant
Files: sendPhoto,
sendDocument, getFile
plannedattachments are encrypted blobs; the gateway will serve decrypted bytes on loopback only, addressed by the bot’s key in the path and signed like every other call
Inline mode
Web Apps
plannedstages 4 and 5 — the dApp browser already has four of the five pieces a Web App host needs

Where this is not Telegram

Four things are not “not yet”. They stand on a platform that does not exist here, and a well-formed error is the honest answer rather than a half-truth:

  • Payments. Telegram’s model is a provider token and Telegram taking custody. Mapping sendInvoice onto our invoices would hand bots a payment they cannot reconcile. Refused, with a native extension offered instead.
  • login_url. It hands a website a Telegram user id signed by Telegram. There is no Telegram user id here; identity is a key pair, which is what the dApp browser already signs logins with. The native mapping is strictly better and the Telegram-shaped one does not exist.
  • Channels, supergroups, admin rights, join requests. MLS groups are flat. chat.type is private or group, and the chat-administration methods answer 400.
  • Stickers, games, business accounts, Premium, boosts, gifts. No counterpart and no plan.

And two that are degraded rather than absent: request_contact returns a Contact with a user_id and names but an empty phone_number, because identity here is a key pair and there are no phone numbers to share; and privacy mode is permanently off, for the reason in the first section.

Before you run it for real

  • Poll from one process. Two pollers for one bot steal each other’s updates. The gateway does not stop you; the symptom is a bot that answers every second message.
  • Advance offset before you handle , not after, so a handler that panics does not put the bot in a loop answering the same message forever.
  • Treat the exported device as the secret. The key in the URL is public and safe to log; the file your application exported is not. Keep it out of images and CI variables — and if it does get out, /mybots takes it back, which is the whole reason the backend holds a device and not the bot.
  • Say what the bot is for when it joins. It reads every message in the group from the moment it is added; the app shows that, and a bot that explains itself first is the difference between a member and a surprise.
  • Re-check every event on arrival. A spec that hides a button is a drawing, not an enforcement.

SELF-HOSTING

Run your own server

  • DockerDocker
  • PostgreSQLPostgreSQL
  • RedisRedis
  • PrometheusPrometheus
  • GrafanaGrafana

Everything above talks to a server. It can be ours or yours, and nothing in a bot changes between the two — the gateway is the same binary either way. The delivery service, the client SDK and the wire formats are one repository, askucher/compax‑core, and the compose file beside them raises everything it needs: Postgres, Redis, blob storage, two instances and a proxy in front of them.

SHELL
git clone https://github.com/askucher/compax-core
cd compax-core/deploy

# placeholders that start; each line says why it is not one to deploy
cp .env.example .env

# the first build compiles the workspace, so it is not quick
docker compose up -d --build
curl localhost:8080/health

One image holds both binaries — the delivery service and the Bot API gateway — built from one workspace and one lockfile, so the schema, the code applying it and the thing talking to it cannot disagree about which of them is older. Migrations run on boot: the first up is also the install.

WhereWhat answers
localhost:8080the service, through the proxy — WebSocket upgrades pass through untouched, and the live channel is one
localhost:8083the Bot API gateway — the base URL /bot<key> hangs off, where the key is the bot’s public key in hex — the gateway looks a call up by it and checks the signature against it
--profile callsthe media server and the relay, for calls and conferences
--profile observabilityPrometheus and Grafana, with the dashboard already provisioned
  • Two instances, by default. Everything correctness‑bearing lives in Postgres, so they are interchangeable and the proxy needs no sticky routing. One instance is the case that works by accident.
  • The proxy speaks plaintext until you give it an address. Which is right behind a load balancer that terminates TLS and wrong as a public edge — SITE_ADDRESS in .env is the whole of turning that around, certificates included, and the section below is what else that takes.
  • The placeholders are placeholders. Compose refuses to start without a Postgres password, a metrics token and the SFU credentials, and .env.example ships values for all of them so the stack comes up before you have real ones. They are not values to deploy, and the file says so beside each.

On another server

The same commands on a host that runs Docker, plus a domain. The address is what turns a laptop into a deployment: set it and the proxy obtains and renews that domain’s certificate itself, leave it empty and it serves plain HTTP, which is right behind a load balancer that terminates TLS and wrong as a public edge.

SHELL
ssh you@your-server
git clone https://github.com/askucher/compax-core
cd compax-core/deploy
cp .env.example .env

# in .env -- the A record must already point at this host
#   SITE_ADDRESS=chat.example.com
#   HTTP_PORT=80        the certificate challenge is answered here
#   HTTPS_PORT=443
# and real values for the eight the file marks as required

docker compose up -d --build
docker compose logs -f server-a server-b

Point clients at https://chat.example.com. The certificate is asked for when the proxy starts rather than on the first request, so the domain has to resolve here before the first up; until it does, the site answers on plain HTTP and the challenge is retried in the background.

  • Two ports, and two more only for calls. 80 and 443 are the deployment — the instances themselves publish on loopback and are reached through the proxy. --profile calls adds 7880 and 7881 with 50000–50100/udp for the media server, and 3478 with 49160–49200/udp for the relay. Set TURN_EXTERNAL_IP to the host’s public address when you raise it: left empty the relay hands out a private one, which answers and never relays.
  • Prometheus and Grafana publish on every interface, unlike everything else here. Behind a firewall or over an ssh -L tunnel they are what they were meant to be; open to the internet, --profile observability is an admin login on port 3000.
  • Updating is git pull and the same up. Migrations run on boot, so there is no second step and no window where the schema and the code disagree.
  • Back up the db volume and botstate. The first is everything correctness‑bearing; the second holds the operator phrase and every bot’s keys, and losing it is losing the bots.

A second server, and how the two see each other

federationmimipeertls

Two instances of one deployment need no configuration at all: they share Postgres and Redis, and everything that decides anything is in the first of those. Two providers are the other thing entirely — separate machines, separate databases, and nothing in common but what each has been told about the other.

They do not discover each other. There is no registry, no gossip and nothing to sign up to: each side is told, in its own .env, and that is the whole mechanism.

SettingWhat it is
HOME_LABELSix hex digits, this deployment’s own. Stamped into every group id it mints, and how an arriving id says whose it is. It cannot change once groups exist — the label is inside the group id, the id is inside the MLS group context, and the context is signed into every commit, so renaming a home means re-creating its groups.
PUBLIC_URLWhere the other side reaches this one. Defaults to http://127.0.0.1:$PORT, which is right on a laptop and useless to anybody else.
SERVER_KEYThirty-two bytes of hex, the key this server’s peer requests are signed with. Written to a file for the instances by docker compose --profile federation. A server without one makes no peer requests and publishes no key — which is a server clients still reach perfectly well.
HOME_DIRECTORYEvery other home, as JSON — inline, or a path to a file. This server’s own entry is not read from it: it is always replaced with this server’s own url and key, because a server is the authority on its own line and on nothing else in the file.
MIMI_DOMAIN, MIMI_BLOCKEDThe domain this deployment answers to on the MIMI lane, and the providers it will not federate with.

Two homes, written out. Each file names the other and nothing about itself beyond its own label and url:

A · .env
HOME_LABEL=a1b2c3
PUBLIC_URL=https://a.example.com
SERVER_KEY=1f4c…                      # openssl rand -hex 32
HOME_DIRECTORY={"homes":{"d4e5f6":{"url":"https://b.example.com","key":"9f2c…"}}}
B · .env
HOME_LABEL=d4e5f6
PUBLIC_URL=https://b.example.com
SERVER_KEY=9f2c…                      # its own, and the public half is what A holds
HOME_DIRECTORY={"homes":{"a1b2c3":{"url":"https://a.example.com","key":"1f4c…"}}}

The key in each entry is the public half of the other side’s SERVER_KEY, and it is what a peer request is checked against — so a directory that lied about somebody else’s key would be a directory whose requests fail a signature check rather than one that succeeds. What each deployment holds is published at /.well-known/chat-homes, so the other side can fetch it rather than be told it twice.

Then raise it with the profile that writes the key: docker compose --profile federation up -d. Everything above is empty by default, and empty is a deployment that is alone — which holds every group it is asked about anyway.

Scaling it to millions

postgresrediss3proxy

One rule makes the rest of this section short: nothing decisive lives in a process. Sessions are rows, routing is Redis, attachments are wherever both instances agreed to put them — so a second instance is a deployment decision and a twentieth is the same decision again. What follows is the order in which things stop being enough, because that order is not obvious and every one of these steps is wasted if it is taken first.

WhenAddWhat it does not fix
One box is busyInstances behind the proxy, and a Redis they shareNothing about the database, which they now share harder
Connections run outPgBouncer in transaction mode, or a bigger max_connectionsWrite throughput — the same rows are still being written
Reads are the loadA replica, for the reads that may be a second staleThe log, which a client reads to catch up and cannot have stale
Writes are the loadA second home, federatedA room that is busy on its own; see the last paragraph
Another continentA region: its own instances, its own RedisLatency to the home that orders a room — that is physics

The backend, which is the easy half

Every instance is interchangeable and none of them is anybody's home: a client may open its socket on any one, and the fanout finds it through the registry rather than by knowing where it went. Two numbers decide how many you can run.

  • Sixteen connections each. That is max_connections(16) in main.rs, so ten instances is a hundred and sixty and a stock Postgres accepts a hundred. Past about five instances the answer is a pooler in transaction mode rather than a larger Postgres, because a connection costs memory whether or not it is doing anything. chat_db_pool_connections by state is the graph that says which of the two you need.
  • One socket per device, not per person. A phone, a desktop and a tab are three, and each one is a file descriptor and a buffer for as long as it is open. chat_ws_sockets_open against the box's own limit is the ceiling; raise nofile before it is reached rather than after.

Attachments have to be shared before the second instance, not after: BLOB_STORE=s3 with S3_BUCKET, and S3_ENDPOINT for MinIO or R2, which speak the same protocol and differ only in that address. A service that finds a live instance keeping its bytes somewhere else refuses to start, and that is deliberate: with two disk directories every download is a coin flip on which instance serves it, and a 404 for bytes that exist is a bug nobody can reproduce.

Redis is per region, and never between two

The registry is written on every connect, and a mobile client reconnects on every change of network — so it is a write-heavy keyspace with a millisecond budget, and a region reaching across an ocean for it turns every reconnection into a round trip. Each region gets its own, and what crosses between regions is the peer lane, which is built for a link that is slow and sometimes down.

The database, in three moves and in this order

First, the partitions you already have. The log is partitioned by month and retention drops a partition rather than deleting rows, so the largest table in the service is bounded by a setting rather than by traffic — see the chart under Storage. Nothing else needs doing until that is true and the graph is flat.

Second, a replica for what may be stale. Less than it sounds: the log is read to catch up, so a replica behind by a second is a client that misses the message it was told about. What moves safely is what nobody is waiting on — the directory reads behind a profile, the public group listings, and the metrics scrapes.

Third, more than one home rather than more than one shard. This is the part where an honest section is worth more than a complete one: the data is cut along two keys already — rooms and identities — and a room fits inside one piece whole, but there is one DATABASE_URL in this code and no router in front of it. What is built is federation, and it divides the same way: a second deployment with its own label, its own database and its own Redis, ordering its own rooms and reaching the first over the peer lane. It is the same cut, made where the code already supports it.

The one thing that does not divide

A room is ordered by one server and one row: one commit per epoch, a compare-and-swap, and every commit in that room waits behind it. Millions of people in millions of rooms is a flat problem — spread them across homes and every home is busy in parallel. A million people in one room is not, and no amount of hardware changes it, because the serialisation is what MLS requires rather than what this implementation chose. That is the number to design the product around, and it is the reason a broadcast channel and a group conversation are not the same feature.

Storage

postgresredisdiskblobsbackup

Two stores, and one question decides which: would anybody want it back after a restart? The log, the directory and the inboxes would, so they are in Postgres. Who is connected, and where to push what to them, would not — that is Redis, and losing all of it costs a reconnect. There is no third store and nothing decisive is cached in a process, which is the whole reason two instances are interchangeable.

Twenty-one tables, sixteen indexes and nine foreign keys — and the arrows are the half a list cannot say: eight of the nine land on devices, every one of them on delete cascade. Deleting a device takes its sessions, its key packages, its wrapped state keys, its push token and the blobs it uploaded with it, because a revocation that left any of those behind would be a revocation that did not revoke.

Every line below is read off migrations/, and what each table says about itself is its own comment in the schema — a map written from memory is a map that is wrong by the second migration. The two marked in accent are the ones every message goes through.

Two things worth noticing in the shape of it. group_log is partitioned by month, so dropping history is dropping a partition rather than deleting a hundred million rows — which is also why the window is a product decision, how long a device may be away, rather than an ops knob. And several tables carry no index on purpose: sessions is swept four times a day by a scan Postgres would have chosen anyway, and an index there would be maintained on every login to serve four queries.

What it will cost is a shape before it is a number, and the shape has a corner in it. Every table here answers to one of two things — how much was said, or how many people there are — and only the second kind keeps rising. The log stops a month in because from then on a day arriving drops a day off the far end, which is retention seen from the outside rather than a second mechanism.

WHAT GROWS, AND WHAT STOPS
How each table grows over ninety daysThe log rises for thirty days and then stays level, because from then on every day that arrives drops a day off the far end; attachments and the sealed archive have the same corner in them, on a window of their own. Without a window the same slope would keep going, drawn dashed. The directory tables rise slowly and do not stop: they are a count of people, not of messages.GROUP_LOG, AND THE INDEXES ON ITWITHOUT THE WINDOWATTACHMENTS, AND SEALED HISTORYDEVICES, NAMES, KEYS: ONE PER PERSONDISK030 · LOG_RETENTION_DAYS6090 DAYS

The window is the forecast. Two of these lines stop rising a month in because that is when a day arriving starts dropping a day off the back; the third answers to how many people there are rather than to how much they said, and nothing retires it.

The arithmetic under the top line is one multiplication. An entry is its ciphertext plus the row around it — call it a kilobyte for a message, which is generous — so ten thousand messages a day is about ten megabytes a day, and the plateau is thirty of those: three hundred megabytes, and it stays there whatever else happens. Attachments are the same sum against a much larger multiplier and they leave with the entry that referred to them. Sealed history has its own knob, ARCHIVE_RETENTION_DAYS, which defaults to the same month for a different reason: how far back a device linked today can see.

The bottom line is the one to watch, because nothing sweeps it. A device costs its row, its key packages, its wrapped state key and a push token — a few kilobytes, once — and a person costs that per device they own. At a hundred thousand devices it is still under a gigabyte, which is why this is a line and not a section: the traffic plateaus and the directory is small, so the forecast that matters is the one for attachments.

Monitoring

prometheusgrafanametricsalerts

Most of what goes wrong in this service goes wrong quietly. A push that cannot be sent is dropped, because clients reconcile. A rate bucket that cannot be read allows the request. A socket queue that fills discards events. Every one of those is a warning in a log at best, and the service keeps working — so the counter beside it is the only thing that says it is happening. That is what these numbers are for, and it is why the alert list is short: each one fires on something otherwise invisible.

The endpoint, and the token on it

Metrics are served on a listener of their own — 127.0.0.1:9091 by default — rather than as a route on the public one, so nothing about them shares a middleware, a rate limiter or a port with what clients talk to. It is always behind a bearer token, loopback included, and that has no exceptions: on a shared host loopback is not a boundary. A token shorter than sixteen bytes is refused at startup.

SERVER.JSON
{
  "metrics": { "bind": "0.0.0.0:9091" }
}

// The token comes from exactly one of three places, and two of
// them being wrong is a startup error rather than a silent default:
//
//   "token":      "<the token>"           here, in this file
//   "token_file": "/run/secrets/metrics"  the shape Docker and
//                                         Kubernetes use
//   METRICS_TOKEN in the environment      what docker-compose does
//
// openssl rand -hex 32

Bringing up Prometheus and Grafana

Both are in the compose file already, behind a profile, so they are one word on the command line rather than a second stack to assemble. Nothing about them starts unless you ask.

SHELL
echo "METRICS_TOKEN=$(openssl rand -hex 32)" >> .env
echo "GRAFANA_PASSWORD=$(openssl rand -hex 16)" >> .env

docker compose --profile observability up -d

# Prometheus  http://localhost:9090
# Grafana     http://localhost:3000   the dashboard is already there

There is a one-shot container between those two lines and Prometheus, and it is worth knowing why it exists: Prometheus reads its credential from a file and expands no variables, so the token in .env becomes a file exactly once, in a volume the two of them share.

What Prometheus is told

Every instance is its own target, scraped directly rather than through the proxy. That is the one line of this config worth explaining: the proxy balances, so a metrics endpoint behind it answers for whichever instance it felt like — a gauge that halves and doubles at random. Scrape each, and sum() at query time is what makes them a fleet again.

PROMETHEUS.YML
global:
  scrape_interval: 15s
  # equal to the scrape interval: an alert is only ever as
  # fresh as the sample it reads
  evaluation_interval: 15s

rule_files:
  - /etc/prometheus/alerts.yml

scrape_configs:
  - job_name: chat-delivery
    metrics_path: /metrics
    authorization:
      type: Bearer
      credentials_file: /etc/prometheus/secrets/metrics_token
    static_configs:
      - targets: ["server-a:9091", "server-b:9091"]

The bot gateway and the faucet export the same way, with the same header and the same metric names, and neither is in that compose file — so their jobs are written out in the comments there rather than guessed at. Give each its own token when they are not one deployment; credentials_file is per job for exactly that reason.

Grafana, provisioned rather than clicked

The datasource and the dashboard are files, mounted read-only, with allowUiUpdates: false. A dashboard whose datasource was chosen in a browser is a dashboard that breaks on the next fresh volume; edits in the browser are for trying things, and the file is what the next deployment gets. What arrives is chat — delivery service, twenty-seven panels: is it up, instances answering against instances in the registry, live sockets, time since housekeeping finished, requests per second and 95th percentile by route, throttling, delivery.

Grafanachat — delivery serviceLast 6 hours15s

Is it up

2Instances answering
2Instances in the registry
1 284Live sockets
41mSince housekeeping
7Requests in flight
3Bots served

Traffic, and delivery

Requests per second, by route

/groups/:id/log/inbox/connect

sum by (route) (rate(chat_http_requests_total[5m]))

95th percentile, by route

/groups/:id/append/blobs

histogram_quantile(0.95, sum by (le, route) (rate(chat_http_request_duration_seconds_bucket[5m])))

The group log

messagecommitepoch conflicts

sum by (kind) (rate(chat_log_appends_total[5m])) sum(rate(chat_epoch_conflicts_total[5m]))

Live events

sentdropped: slow socket

sum(rate(chat_ws_events_sent_total[5m])) sum by (reason) (rate(chat_ws_events_dropped_total[5m]))
Four of the twenty-two panels, with the queries they are drawn from. The numbers are invented; the queries are the ones in the file. Download the dashboard — import it into your own Grafana, and point it at the Prometheus above.

TWO DOORS THIS OPENS

Prometheus is published on 9090 behind no password at all — that is the whole operational picture of the deployment, and Prometheus itself does not ask. It belongs on a private network or behind something that does.

Grafana starts with anonymous viewers enabled, which is right on a laptop and wrong anywhere public. Set GF_AUTH_ANONYMOUS_ENABLED=false before it sits where anyone can reach it; the admin password is already required to start.

What is exported

Ninety-one metrics, and they group into eleven families. Nearly two thirds are counters, because most of what is worth knowing here is a rate rather than a level — and a counter survives a restart being read as a reset, which a gauge does not.

FamilyMetricsWhat only these say
Requestschat_http_requests_total chat_http_request_duration_seconds chat_http_requests_in_flightRate, status and latency by route, and how many are being served right now.
Socketschat_ws_sockets_open chat_ws_events_sent_total chat_ws_events_dropped_total chat_ws_connection_duration_secondsWhat is connected, what was pushed, and what was thrown away because a socket would not take it — the one that is otherwise silent.
The logchat_log_appends_total chat_epoch_conflicts_total chat_roster_unchecked_totalCommits and messages appended, and how often two devices raced for the same epoch. A storm of conflicts is a client bug seen from the server.
Between instanceschat_bus_envelopes_sent_total chat_bus_envelopes_received_total chat_bus_faults_total chat_fleet_instancesFanout between instances, and the registry’s own count of them — which is what an alert compares with the number that answered a scrape.
Identitychat_auth_verifications_total chat_sessions_resolved_total chat_keypackage_claims_total chat_expired_attestations_refused_totalLogins, sessions, and whether anybody can still be added: a claim that finds no key packages is somebody who cannot be invited.
Attachmentschat_blob_operations_total chat_blob_bytes_total chat_blob_signed_urls_totalBytes each way, and failures by kind. The only family whose growth is not bounded by the retention window.
Under loadchat_rate_limited_total chat_rate_bucket_faults_total chat_pow_demanded_total chat_shed_total chat_pressure_levelWhat was throttled, what was refused work, and the level the shedder is at. A bucket that faults allows the request, so this counter is the only sign it happened.
Federationchat_mimi_calls_total chat_mimi_connections_open chat_peer_redeliveries_total chat_peer_redelivery_gaps_total chat_relay_forwarded_totalEvery call across the lane by endpoint and result, the sweep that catches a provider up, and the gaps it found — a gap is delivery that would once have been lost.
Housekeepingchat_maintenance_runs_total chat_maintenance_swept_total chat_maintenance_last_success_timestamp_secondsWhat each pass dropped, and when one last finished. A timestamp that stops moving is retention that stopped happening.
Botschat_bots_serving chat_bot_api_calls_total chat_bot_api_call_duration_seconds chat_bot_updates_delivered_totalThe gateway’s side: calls by method and result, updates handed over, and how many bots are being served.
The machinechat_db_pool_connections chat_background_panics_total chat_metrics_scrapes_total chat_build_infoThe pool by state, tasks that died, scrapes refused for a bad token, and which build is answering.

What is worth waking somebody for

Fourteen rules, and the test each one had to pass is the same: it fires on something that would otherwise stay invisible. InstanceDown and more than one percent of requests failing are the two that page. The rest are tickets — the instance registry disagreeing with the scrape list, the limiter failing open, fanout flapping, sockets dropping events, housekeeping stopped, an epoch-conflict storm, key packages exhausted, a provider unreachable, blobs missing, the database pool saturated, and somebody probing the metrics endpoint.

ALERTS.YML — TWO OF THE FOURTEEN
- alert: FleetDisagrees
  # The registry's own count against the number of targets that
  # answered. They disagree when an instance died without
  # unregistering, or somebody scaled out and did not tell Prometheus.
  expr: max(chat_fleet_instances) != count(up{job="chat-delivery"} == 1)
  for: 15m
  labels: { severity: ticket }

- alert: ServerErrors
  expr: |
    sum(rate(chat_http_requests_total{status=~"5.."}[5m]))
      / sum(rate(chat_http_requests_total[5m])) > 0.01
  for: 5m
  labels: { severity: page }

One rule about the metric names themselves: no device id, no identity, no token, no slug, no name. A label that names a person is a label that turns a dashboard into a record of who talked to whom — see the header of crates/observe/src/lib.rs.

HOW THE SERVER WORKS

What the service is, and what it refuses to be

  • PostgreSQLPostgreSQL
  • MLSMLS

One sentence, and everything else follows from it: the delivery service orders messages and cannot read them. It is not a store of conversations, not an authority on who is in a room, and not a party to anybody’s encryption. Every design decision below is what that costs or what it buys.

THE PATH OF A MESSAGE
What the server does with a messageA member’s device sends one message. The server orders it — the epoch compared under a lock, the roster read from the ratchet tree, the entry appended to the log — and then fans it out to devices on this instance, on any other instance, and to another provider’s members, ringing a doorbell that carries nothing.EPOCH COMPARED UNDER A LOCKROSTER READ FROM THE TREEAPPENDED TO THE LOGDEVICES ON THIS INSTANCEDEVICES ON ANY OTHERANOTHER PROVIDER’S MEMBERSA SOCKET, A PUSH — EMPTYA MEMBER’S DEVICEORDERED, ORREFUSED 409FANNED OUTTOLD TOCOME ANDREAD

The only decision in the picture is the first one. Everything to the right of it is delivery, and none of it can read what it carries.

One order per room, and where it comes from

MLS requires every member to apply the same commits in the same order. A commit accepted out of turn splits a group into two incompatible key states, and unlike a database there is no rollback — applying a commit destroys the previous epoch’s secrets by design.

So the whole ordering guarantee is one comparison: a room’s row is locked, the sender’s epoch is compared to the room’s, and a sender whose epoch is stale is refused rather than merged. That is it. There is no consensus, no resolution, no reconciliation — the cheapest correct thing, and the reason a busy room’s throughput is a number worth knowing before somebody discovers it.

The honest cost: a commit needs a round trip to the room’s home server, and two members committing at once means one of them is told to try again. A design that merged instead would be a design that could not use MLS.

TWO COMMITS, ONE LOCK
Two commits arriving at onceTwo devices commit at the same moment. The room’s row is locked, each sender’s epoch is compared with the room’s, and the one that is stale is refused rather than merged — it reads the new epoch and tries again.ACCEPTED — THE ROOM MOVES TO THE NEXT EPOCHREFUSED 409 — READ THE NEW EPOCH AND TRY AGAINTHE ROOM’S ROW, LOCKEDONE DEVICE COMMITSSO DOES ANOTHER

There is no merge and no resolution — the whole ordering guarantee is one comparison, and the loser is told to try again.

Following a room without holding a key

The server has to know who is in a room — that is who a message is fanned out to. It used to be told: the sender named the devices it had just added, and the server believed it. Two lies were available to any modified client. Naming a device that is not in the ratchet tree subscribes it to every ciphertext the room produces forever; it cannot read them, but it learns who talks to whom and how often. Omitting a device that is in the tree stops its fanout while it remains a member, so it falls behind and its next commit is refused on the epoch barrier — one member evicting another, silently.

Handshake messages travel in the clear already, so the server reads the roster from the tree itself and neither lie stays expressible. It learns nothing it did not already have on disk, and it still holds no key.

WHERE THE ROSTER COMES FROM
Being told the roster, and reading itWhen the sender named the devices it had added, two lies were available to any modified client: naming a device that is not in the ratchet tree, which subscribes it to every ciphertext for ever, and omitting one that is, which silently evicts a member. Reading the roster from the tree leaves neither expressible.TOLD BY THE SENDERREAD FROM THE TREEA DEVICE NOT IN THE TREE, SUBSCRIBED FOR EVERA MEMBER’S DEVICE OMITTED, AND SILENTLY EVICTEDTHE ROSTER, AND NOTHING ELSE

The server has to know who is in a room — that is who a message goes to. Being told was the version with two lies in it.

Shadow first

New rules that could refuse something run for a while without refusing anything. They run, they agree or they disagree, and the disagreement is counted. A rule switched on the day it is written is a rule whose false refusals arrive as somebody’s conversation breaking; a rule that has disagreed with reality zero times for a month is one that can be switched on.

A RULE’S FIRST MONTH
How a new rule is switched onA rule that could refuse something runs for a month without refusing anything. It agrees or it disagrees, the disagreements are counted, and only a rule that has disagreed with reality zero times is switched on.WRITTENRUNS, REFUSINGNOTHINGDISAGREEMENTSCOUNTEDA MONTH AT ZEROSWITCHED ON

A rule switched on the day it is written is a rule whose false refusals arrive as somebody’s conversation breaking.

The log is a catch-up buffer, not an archive

This is the decision most likely to surprise, so it is worth stating plainly. The service keeps a room’s log long enough for a device that was away to read it, and no longer — thirty days by default. Once every member has read past an entry it is dead weight, and forward secrecy means nobody could decrypt it later anyway.

So retention is not an ops knob dressed as a product one. It is the longest a device may stay offline before it must re-join its rooms, said in days. History lives in the application, on the devices, sealed — or it does not exist.

THE LOG IS A WINDOW
What the log holds, and for how longThe log runs from the oldest partition still kept to today. Thirty days back is where whole partitions are dropped; a device that was away reads from where it left off, and one that was away longer than the window has to re-join.DROPPED — AWHOLE PARTITIONTHIRTY DAYS BACKWHERE A DEVICELEFT OFFTODAY

Retention is not an ops knob dressed as a product one: it is the longest a device may stay offline before it must re-join its rooms.

A cursor, not a queue

Nothing here holds a copy of anything for anybody. A device catching up reads the log from where it left off; another provider being caught up is a mark saying how far it has been told. There is no outbox to fill, nothing to leak if it is never drained, and nothing to reconcile if two of them disagree — the log is the truth and a position in it is the whole of the state.

A live socket is a doorbell, not the delivery. A push notification is an empty wake-up. Both exist to shorten the wait; the log is what is actually read, which is why a missed push costs nothing and why a push can carry no content at all.

A CURSOR, NOT A QUEUE
Positions in the log, rather than copies of itThe log is the truth and a position in it is the whole of the state: a device catching up reads from where it left off, another provider is a mark saying how far it has been told, and a member up to date is at the end. A socket is a doorbell, not the delivery.A PROVIDER’SMARKA DEVICE THATWAS AWAYSOMEBODY UP TODATE

Nothing here holds a copy of anything for anybody. There is no outbox to fill, nothing to leak if it is never drained, and nothing to reconcile.

What a device that was away comes back to

A client that reconnects does not ask anybody what it missed. The live channel begins by reading the mailbox and then each room’s log from the last sequence number it holds, so everything that happened while it was away arrives as the same messages it would have received at the time, in the order they were written. Control messages are part of that: a room renamed, a room’s picture changed, somebody’s profile announced — their name, their photograph, the line they wrote about themselves — a reel posted, a reel watched. They are applied in sequence, so the state a device ends up in is the state everybody else has been in all along.

An announcement that changes nothing is applied and not reported. The room says what it is called and what it looks like again every time somebody is added — a member who joins at epoch nine cannot read what was said at epoch one — so everybody already in it hears it a second time and a third. The client writes a line into the conversation only when the value it holds actually changes.

What replaying the log cannot repair is what is no longer in it. Retention is a limit on how long a device may be away, not a limit on history: past it, the device re-joins its rooms rather than reading them, and an announcement old enough to have been trimmed can never be replayed — a copy of a profile that went stale behind it would stay stale for ever. Reels expire on their own after a day, so one posted and gone while a device was off is simply never seen, which is the point of them.

So each device republishes its own profile when it connects, and at most once a day. That is one control message per room, from the only party that knows the facts, and it means any stale copy of a name, a photograph or a description repairs itself within a day of both ends having been online — without anybody asking anybody for anything, and without the service holding a directory of who is called what.

Two instances, and why the second one is free

Everything correctness-bearing is in Postgres, so instances are interchangeable and the proxy in front of them needs no sticky routing. One instance is the case that works by accident.

The one thing that does not follow from that is fanout: a message landing on instance B has to reach a socket held by A. It was done through Postgres notifications, and the argument was good — nothing new to operate. What it missed is that a notification is a broadcast: every instance parsed every event in the deployment to find the few it held sockets for, so ten instances did ten times the work to deliver the same messages. That is the exact opposite of what a second instance is bought for. It is addressed now, and the rate buckets moved the same way — one bucket per subject for the whole fleet, so the numbers in the file are the numbers in force however many instances are running.

WHY THE SECOND INSTANCE IS FREE
Fanout between instancesA message landing on one instance has to reach a socket held by another. Done as a database notification, every instance in the deployment parsed every event to find the few it held sockets for. Addressed to the instance that holds the socket, only that one is woken.A NOTIFICATION TOEVERY ONEONE HOP TO THE ONETHAT HOLDS ITINSTANCE A — HOLDS THE SOCKETINSTANCE B — PARSES, DISCARDSINSTANCE C — PARSES, DISCARDSINSTANCE D — PARSES, DISCARDS

A notification is a broadcast: every instance parsed every event to find the few it held sockets for, so ten instances did ten times the work.

Sharding, which is not federation

Two different things are easy to confuse here, and the difference is who is on the other side. Federation is two providers who do not trust each other exchanging signed messages — different operators, different databases, a protocol between them. Sharding is one provider whose database is cut into pieces: one operator, one deployment, one set of rules, and the pieces answer to the same code. A room does not know which shard it is on, and no client ever addresses one.

What makes the cut possible is the same fact that makes the room’s home work: the only guarantee that has to be strict is one commit per epoch, and that is a compare-and-swap on one row. Everything the write path touches — the room, its roster, its log — is keyed by the room. So a room fits inside one piece whole, and no transaction has to cross anything.

There are two natural keys, not one: rooms, and people. Devices, sessions, key packages, sealed state and the inbox all follow the identity that owns them, so logging in and checking whether a device is still one its identity stands behind stay in one place too. The seam is where a question about a room needs an answer about a person, and there was one such question on every read and every append — is this device’s owner in this room. The roster stores the identity now, so the room answers it alone.

That was worth doing before any piece was cut. It removes a join from the hottest query in the service, which is a plain win on a single database — and the ability to divide the data later is the consequence rather than the reason.

When the request came to the wrong server

A room has one home, written into its identifier. A request for a room somebody else orders is answered 421 with the label of the home that does — not 404, because the room exists and the request is well formed, and not forwarded, because a server that quietly ordered somebody else’s room would produce a working conversation nobody else could see. That is the worst shape a routing bug can take. The client re-reads the directory and asks the right one; nobody is told anything.

WHEN THE REQUEST CAME TO THE WRONG SERVER
A request for a room this server does not orderA room has one home, written into its identifier. Answering 404 would be false — the room exists and the request is well formed — and forwarding would quietly produce a second ordering. The answer is 421 with the label of the home that does order it.404 — FALSE: THE ROOM EXISTS AND THE REQUEST IS GOODFORWARDED — WORSE: AN ORDERING NOBODY ELSE SEES421, WITH THE LABEL OF THE HOME THAT DOESA SERVER THAT IS NOTITS HOMEA REQUEST FOR SOMEBODYELSE’S ROOM

A server that quietly ordered somebody else’s room would produce a working conversation nobody else could see — the worst shape a routing bug can take.

Retention and cleanup

Housekeeping runs every six hours. Old log data leaves by dropping a whole monthly partition rather than deleting rows — the difference between instant and a vacuum backlog that arrives days later as mysterious slowness.

WhatWhen it goes
Log partitionspast LOG_RETENTION_DAYS — 30 by default
Attachments, and files on disk no row refers towith the log they belonged to; orphans are swept separately, because a file nothing points at is invisible to every other check
Sealed history windowspast ARCHIVE_RETENTION_DAYS — a knob of its own, though it defaults to the same month: one says how long a device may be away, the other how far back a newly linked device can see
Inbox rows, spent key packages, expired sessions and challengeswhen they can no longer be used for anything
Sessions of a device whose attestation ran outcounted apart from sessions that expired on their own — a valid session held by a device that is no longer valid is a different fact
Uncollected linking parcelsafter fifteen minutes — long enough to fetch the other device from the next room, short enough that a sealed key is not left on a server because a linking was abandoned halfway
Spent relay noncespast the window their envelope could be presented in — the only thing bounding a table every relayed request writes to

Every one of those is counted, and the counts are the point rather than a nicety: a number that stops moving is how an operator finds out a sweep stopped running. A sweep that silently stopped looks exactly like a deployment nobody is using.

WHAT IT KEEPS, AND FOR HOW LONG
How long the service keeps each thingLog partitions and sealed history live thirty days, attachments leave with the log they belonged to, and linking parcels and relay nonces last minutes. The circle is the lifetime.30 DAYSLOG PARTITIONS30 DAYSSEALED HISTORYWITH ITS LOGATTACHMENTSTILL IT EXPIRESA SESSIONONCE SPENTKEY PACKAGES15 MINUTESLINKING PARCELSONE WINDOWRELAY NONCES

The circle is the lifetime. Two of them are a clock and the rest are an event — spent, expired, collected — and none of them is an archive.

LIMITATIONS

Limitations

  • RedisRedis
  • Proof of workProof of work

Enrolment is open and self-service. An attestation is self-signed, so making a device costs nothing and making a thousand of them costs nothing either — none of that is a break-in, and all of it is somebody else’s machine full. Three separate things stand in the way, and they answer three different questions.

WhatThe question it answers
Rate limitsWhat may one caller do, whoever else is here. A token bucket per action, in Redis rather than in each process — so a fleet of ten enforces the file once rather than ten times.
Proof of workWhat does volume cost. Zero below a person’s rate; past it, doubling. It is what a rate limit cannot do, because making more devices walks around one.
CongestionWhat will the server do at all, whoever is asking. A deployment can be inside every per-caller limit and still be out of disk: a hundred people behaving perfectly is still a hundred people.

Work, and only when somebody is being expensive

Below the allowance the demand is zero — not a small puzzle, no puzzle, no header, no cost. Past it each step of volume adds a bit, and a bit is a doubling. The shape is the point: somebody typing has a rate a human hand sets, somebody pasting has a size a human patience sets, and a script has neither.

The defaults are read from published usage rather than guessed. A messaging user sends on the order of forty messages a day over about thirty-five minutes in the application — roughly one a minute while actually using it. And message length has been measured repeatedly in the same place: mean 58.7 characters, median 50.5, mode 32. So the allowance sits at sixty messages and 256 KiB a minute: about fifty times what a person does, and under a second’s work for a loop.

A proof covers the server’s rotating seed, the device, and the exact bytes being sent. Without the seed it could be computed a week early; without the device one solved puzzle would spend for a thousand of them; without the body it would be reusable, which is the flood again with one puzzle solved. The refusal carries all three so a client can answer rather than guess — and it answers once, because a second demand means the difficulty rose while the work was being done, and retrying that forever is a phone getting hot instead of an error.

This is not a defence against somebody willing to spend money on hardware, and does not claim to be. What it changes is the economics of the cheap case — a laptop, a loop, and nothing to lose — which is the case that actually shows up.

WHAT A SENDER IS ASKED TO PAY
How the work rises with the rateUnder sixty messages a minute, or a quarter of a megabyte, the server asks for nothing. Past that the difficulty rises one bit per twenty messages or per further quarter megabyte, whichever is further past its line, and stops at twenty-two bits.UP TO 60 A MINUTENO WORK AT ALL — THE ORDINARY CASE80 A MINUTEONE BIT — A MILLISECOND200 A MINUTESEVEN BITS500 A MINUTETWENTY-TWO BITS — THE CEILINGFASTER STILLSTILL TWENTY-TWO — IT DOES NOT RUN AWAY

Nothing is asked of an ordinary sender. The cost rises with whichever of the two lines — messages or bytes — is further past its allowance, and stops at a ceiling a phone can still meet.

Giving things up in order, rather than falling over

A server out of CPU does not refuse politely. It gets slow, then slower, then the health check times out and something restarts it in the middle of everybody’s conversation. Refusing a video upload is a thing one person notices for a minute; falling over is a thing everybody notices for ten.

Three readings, and the worst of them decides rather than the average — a machine with plenty of CPU and no disk is not half fine. Load average per core, so one file means the same on a laptop and on a sixty-four core machine. Free space where attachments are written, which is the one with a floor under it because a full disk fails for everything at once, including the database. And bytes this service moved — not what the network card did, which includes everybody else on the machine and is not ours to answer for.

LevelWhat stops being offered
busyAttachments get smaller. A photograph still goes; a video does not.
congestedAttachments smaller still, and no new calls. Calls already up are left alone — dropping people mid-sentence to save CPU they were already spending saves nothing.
criticalNo attachments and no voice messages. Text and signalling only.

The order is the argument: largest and least urgent first, and text is never refused at any level. It has no knob in the file, which is how it is guaranteed rather than configured — a messenger that cannot carry a sentence has not degraded, it has stopped.

A voice message is the one the server cannot judge for itself: it holds ciphertext, and a voice note is not distinguishable from any other audio by size. So /info says what is not on offer and the application hides the control, with the size ceiling as the backstop rather than the rule. A button that fails when pressed is indistinguishable from a bug.

WHAT GOES FIRST WHEN IT IS BUSY
What the server sheds as pressure risesCPU, disk and network are sampled, and whichever is worst sets the level. Busy caps uploads, congested refuses voice, critical refuses calls as well — and text keeps going at every level.CLEAREVERYTHING, UP TO THE ORDINARY LIMITBUSYSMALLER FILES; VOICE AND CALLS STILL GOCONGESTEDSMALLER STILL, AND NO VOICE MESSAGESCRITICALNO NEW CALLS EITHER — TEXT KEEPS GOING

Shedding in a stated order beats falling over: the parts that cost the most go before the part everything else depends on.

One file, and it can be TOML

All three live in the same file — pointed at by CHAT_SERVER_CONFIG, or ./server.json if that is unset. The language is chosen by the file’s name rather than by trying one and falling back: a TOML file with a mistake in it reported as bad JSON is an error about the wrong language, on the wrong line, for somebody who never wrote any JSON.

SERVER.TOML
[pow]
free_messages = 60              # ~50x what a person does in a minute
free_bytes = 262144             # a message is tens of bytes of text
messages_per_bit = 20           # each further step doubles the work
max_bits = 22                   # a few seconds on a phone, and no more

[congestion]
cpu_critical = 4.0              # load average per core
disk_free_critical = 536870912  # 512 MiB — the last chance to stop
max_upload_busy = 8388608       # 8 MiB — a photo goes, a video does not
refuse_calls_at = "congested"
refuse_voice_at = "critical"
  • Every field is optional. Name the ones you want to move; a file that is not there at all is the ordinary case and means the defaults. A file that is there and will not parse is a startup error, because limits that silently failed to load are limits nobody is running.
  • The congestion section can only narrow. It never raises a ceiling — otherwise the real maximum lives in two places, and the smaller one is not always the one in force.
  • These numbers are placeholders with their reasoning attached. Published averages are other people’s users on other people’s products. The service records what yours do — chat_messages_per_device_minute, chat_message_bytes, chat_pressure_level — so the next set can be read off a running deployment instead of cited. Refusals climbing while those histograms sit low means the allowance is too tight, not that somebody is flooding.

FEDERATION

More than one server, and what has to be true between them

  • MIMIMIMI
  • MLSMLS

Federation here is not a network of equals passing copies around. It is one sentence: every room has exactly one server deciding its order, and everything else is delivery. MLS requires one commit per epoch, and that guarantee is a compare-and-swap on a single row — it cannot be distributed, only assigned. So the interesting question is never who agrees; it is who was assigned, how everybody else finds out, and what happens to the ones who are somewhere else.

Three protocols, and which one is spoken depends on who is at each end. A device talks to the provider it has an account at over ordinary HTTPS, with one WebSocket for what it has to be told rather than ask for. It talks to another provider the same way and directly — opening an account there is an attestation and a signature, not a favour anybody has to grant. Providers talk to each other in draft-ietf-mimi-protocol over mutual TLS, at endpoints each reads out of the other’s directory.

WHO TALKS TO WHOM, AND OVER WHAT
How devices and providers reach each otherA device speaks to its own provider over HTTPS and one WebSocket, and speaks to another provider the same way, directly, with an account it opened there itself. When it cannot reach that provider at all, its own carries the request, signed. Providers speak to each other in MIMI over mutual TLS, at endpoints each reads from the other’s directory.HTTPS, ANDONE WEBSOCKETHTTPS, ANDONE WEBSOCKETMIMI, OVER MUTUAL TLSENDPOINTS FROM ITS DIRECTORYDIRECTLY, WITH AN ACCOUNT OF ITS OWNOR CARRIED BY ITS OWN, SIGNED PER REQUESTA DEVICEA DEVICEITS OWN PROVIDERANOTHER PROVIDER

A device is not tied to one server: it holds an account wherever a room it is in is ordered, because proving who it is takes an attestation and a signature rather than anybody’s permission. Between operators the protocol is a different one, and the lane it runs on is opened by agreement rather than by a certificate the world will issue.

The dashed line is the only fallback in the picture: a home this device cannot reach at all is reached through its own, which carries a request it cannot read, alter or replay. Read the solid axis left to right and it is also the other arrangement — a room another provider orders, followed here on the device’s behalf and read locally like any other. Which of the two a room uses is settled at the invitation, not by its identifier.

A room has one home, and its name says which

The assignment has to be readable by anyone holding the room’s identifier — otherwise there is a global registry of rooms somewhere, which is the thing being avoided. So it is in the identifier, in bytes both sides read the same way.

WHAT A ROOM’S NAME SAYS
The fields of a room identifierThirty-two bytes: a magic byte, the version of the rules the room was made under, a three-byte label naming the home that orders it, and twenty-seven random. Anyone holding the identifier can read the home and the rules without parsing any MLS.1 BYTEMAGIC1 BYTEVERSION3 BYTESHOME27 BYTESRANDOM

A domain here would be permanent — the identifier is signed into every commit — and would tell everyone who sees one which operator runs the room. A label costs a directory file instead. The counts are printed because the two narrow fields are drawn wider than their share, to fit a label in.

The home is an opaque three-byte label, not a domain. A domain would be permanent: the identifier lives in the MLS group context and is signed into every commit, so renaming or moving an operator would mean re-creating every room it holds. It would also tell everyone who ever sees a commit which operator runs the room. A label costs a small directory mapping it to an address, and that directory is a file anyone may edit.

The version byte is there for the change nobody survives: rules that shift under bytes that still decode. Nothing else here catches it — the server does not read what is inside a room, and MLS does not tell an application message from any other. So the rules a room was made under travel with its name, and a byte declares rather than enforces: the refusals sit at the two doors that enter a room, not on the read path, because a rule applied to a room already full of conversation takes the conversation with it.

Ask the wrong server and it answers 421 with the label of the one that does — never 404, and never by forwarding, for the reasons under the wrong server. That refusal is also the one a client can act on alone: the identifier names its home in bytes both sides agree on, so a 421 cannot mean the client got the home wrong — only that the label resolved to the wrong address. It re-reads the directory and asks again, once. A second 421 after a fresh directory is a fleet disagreeing with itself, and retrying into that produces a client that hangs instead of one that reports something an operator can act on.

The lane between providers

Two instances of one deployment are interchangeable and share a database; two operators share nothing. The second case is a wire protocol, and the one being spoken is draft-ietf-mimi-protocol — the IETF’s work on messaging interoperability. Paths are not hard-coded: a peer reads /.well-known/mimi-protocol-directory and finds the endpoints there. Every field in it is optional, which is how a peer discovers what a provider does not offer — more useful than finding out from a 404 halfway through a flow.

WHAT ONE PROVIDER ASKS ANOTHER
The endpoints between providersA peer provider claims key material and asks who somebody is, submits commits to be ordered and fetches the tree to join by, sends what was said and fans out what was ordered, fetches attachments through the provider, and reports abuse./KEYMATERIAL/IDENTIFIERQUERY/UPDATE/GROUPINFO/SUBMITMESSAGE/NOTIFY/PROXYDOWNLOAD/REPORTABUSEA PEER PROVIDERWHO SOMEBODY ISWHAT CHANGES A ROOMWHAT IS SAIDAN ATTACHMENT, FETCHEDA COMPLAINT

Every path is discovered rather than assumed: a peer reads the directory at a well-known address and finds out what is on offer there, including what is not.

Authentication is mutual TLS, and the peer roots are the deployment’s own, not the system’s. A peer is somebody this operator agreed to federate with, and “any certificate the world will issue” is not that agreement.

  • A half-configured lane is a startup error, named variable by variable and all at once. The alternative is a lane that fails at the first peer connection — where the failure belongs to somebody else’s request and looks like their problem.
  • A domain with no lane running serves an empty directory, and warns. Advertising endpoints nobody can connect to is the one promise the directory exists not to make.
  • The certificate says which provider, not who asked. So claiming key material must additionally be signed by the key it names. A claim spends a single-use package: unchecked, a peer could compose requests for any of its users and burn through this deployment’s people’s packages. What the signature establishes is stated no more strongly than it is — that whoever composed the request holds that private key. Not who the key belongs to, because a credential issued by another provider is one this deployment cannot verify.
  • Addresses may be told rather than resolved. MIMI_PEER_ADDRESSES pins domain=host:port where DNS is not the answer. It pins an address and never an identity — the certificate is still checked against the name, so pointing a domain somewhere does not make whatever answers there that domain.

Adding somebody at another provider

None of the hops is one the adding client could make itself. Their key material is claimed through their own provider, their leaf is committed into the tree here and ordered at the hub, and only then does the Welcome go out to be carried.

ADDING SOMEBODY AT ANOTHER PROVIDER
How a person at another provider is addedTheir key material is claimed through their own provider, their leaf is committed into the tree and ordered at the hub, and only then is the Welcome carried to their provider, which matches it to one of its own people and puts it in their inbox.CLAIM THEIR KEYMATERIALCOMMIT THEIRLEAFORDERED AT THEHUBTHE WELCOME,CARRIEDTHEIR INBOX

The order is the whole of it. Somebody who accepted an invitation to an epoch the hub never accepted is a member of a group nobody else is in.

Order is the whole of it. Claim, commit, then the Welcome — somebody who accepted an invitation to an epoch the hub never accepted is a member of a group nobody else is in.

  • Who to notify is read, not accepted. The providers a room touches come out of the ratchet tree, never out of the request. A client naming its own list could have this server deliver a room’s Welcomes to a provider nobody in the room belongs to — telling them the room exists, who is in it, and handing them ciphertext to keep.
  • Who a Welcome is for is worked out, not stated. Its secrets are addressed to key package references, which is MLS’s own name for a joiner, so the fanout names no recipient and does not have to. A reference nothing matches is skipped — that is the ordinary shape of a Welcome addressed to several people at several providers.
  • A foreign key package takes the same road as a local one, so it meets exactly the validation a local one does. That is what makes a foreign leaf safe to have in the tree at all.
  • Fanout accepts Welcomes and refuses the rest, loudly. A commit or an application message would need this deployment to hold the log of a room it does not order. A peer told its messages arrived and finding nobody read them has been lied to.

Following a room somebody else orders

A room this deployment does not order is followed, and everything follows from that one distinction. There is no second barrier: the barrier exists so one server decides the order, and for a followed room that server is the hub. Entries arrive from it, are appended in the order they came, and are numbered locally, because a local sequence is what a client here reads by. The contents are not taken on trust — every commit and message is validated by each member’s own MLS state, exactly as if it had been ordered here.

A WRITE IN A ROOM ORDERED ELSEWHERE
Where a write goes in a followed roomA provider that follows a room rather than ordering it appends what the hub sends, in the order it came. A write from a member here is refused before the barrier and submitted to the hub instead, and comes back numbered locally like everything else.APPENDED HERE — A SECOND OPINION ON A SETTLEDQUESTIONSUBMITTED TO THE HUB, AND COMES BACK NUMBEREDTHIS PROVIDER, WHICHDOES NOT ORDER ITA WRITE IN A FOLLOWEDROOM

The barrier exists so that one server decides the order. A second one here would be a second opinion about a question with one answer.

  • Its own copy has to come back recognisable. The hub returns entries with no sender, which is right for everybody else’s and wrong for one’s own: MLS refuses to let a sender read their own ciphertext. So the one thing this provider knows — that it carried these exact bytes for this device — is remembered under the ciphertext’s hash for the length of the round trip.
  • The client has to be told where to read. Nothing in an identifier could say it: the question is not whose label it is but who is holding a copy. The flag is persisted at join, from the Welcome.
  • A commit is not pushed at the provider it adds. They have not been told the room exists — the invitation is a separate hop and has to be — so the fanout uses the tree from before the entry. They get the tree with the Welcome.
  • A change to the room goes through the same barrier. What the hub checks is what MLS checks: that the asking provider has somebody in this room. No more, because within a room any member may commit and what a commit may do is decided by every member validating it, not by a server’s opinion. No less, because otherwise it is ordering strangers’ commits into somebody’s room.
  • The joiner is told where to start reading, from just after the commit that added them — which only the hub knows. Sending them to the start of the log hands them messages from epochs they were never in, which MLS refuses, correctly and unhelpfully.

A provider that was down

Everything about federating was first proved inside one process: two servers, two databases, loopback ports, a certificate authority the test invents. Running them as two real processes showed something that arrangement could not. Stop one provider, send a message to a room somebody there is in, start it again: the message is gone, permanently.

The reasoning that allowed it is right about the case it was written for. A fanout has never been the delivery guarantee, because the log is — a member who misses a push reads the log and catches up. For a member at another provider it does not hold: their provider’s copy of the room is those pushes. There is no second source to catch up from, so a push that failed while they were down is not a delayed message but a lost one, and nothing said so.

A PROVIDER THAT WAS DOWN
What a peer that was down gets backWith only a push, a message sent while a peer was down was lost permanently and nothing said so. A cursor per room and provider — how far that provider has been told — is advanced only by the entry that comes next, and a sweep offers everything from behind, in order.A PUSH, AND NOTHINGELSEA CURSOR PER ROOM ANDPROVIDERDELIVERED WHILE THE PEER WAS UPLOST FOR GOOD WHILE IT WAS DOWNOFFERED AGAIN, IN ORDER, FROM BEHINDBOUNDED BY THE LOG’S OWN RETENTION

A fanout was never the delivery guarantee, because the log is — and for a member at another provider that reasoning does not hold. Their provider’s copy of the room is built from those pushes.

The fix is a cursor, not a queue: one number per room and provider saying how far that provider has been told. A push advances it only when the entry it delivered is exactly the next one — a success out of order leaves it alone, so a hole can never be stepped over — and a sweep offers everything from behind, in order, stopping at the first refusal. Nothing queues a payload, because the log already holds every entry; the log’s own retention is therefore the bound, and an entry that has aged out was never going to be delivered by any amount of retrying. Rows are taken with for update skip locked, so instances share the pass rather than racing, and a provider whose last member leaves is forgotten on the next commit — the tree is the only thing that knows.

The part worth keeping is not the cursor. It is that a rule which is correct in one place was carried to a place where it is not, and that only two real processes could show it.

When a client cannot reach a home

A device that cannot open a connection to a room’s home is carried there by its own. This was refused for a long time on a good argument — talking to a home directly needs no trust between servers, while forwarding appeared to need it — and the argument turns out to be against vouching, a server asserting “this is Bob”. It does not touch a design where the device signs for itself.

A HOME THE DEVICE CANNOT REACH
A request carried to a home the device cannot reachA device tries the home directly and falls back only on a failure to reach it. The request is signed against its method, its path, its body and the home it is addressed to, and the far home verifies the device against its own records rather than the carrier’s claim.TRIED DIRECTLYFIRSTUNREACHABLESIGNED FOR THEROUTECARRIED BY ITSOWN HOMETHE FAR HOMEVERIFIES THEDEVICE

The carrier holds bytes. It cannot forge a request, alter one, move it to another route or another home, or send it twice — and it cannot sign for the device whose key it can plainly see.

The envelope covers the method, the path and query, the body, a timestamp, a nonce and the home it is addressed to, signed with the same device key that proves possession at login. A carrier holds bytes: it cannot forge a request, alter one, move it to another route or another home, or send it twice — and it cannot sign for the device whose key it can plainly see in every envelope passing through it.

  • Direct first, carried on failure. Only a failure to reach a home falls back. A home that answered — with a refusal, a 401, anything — has given an answer, and asking again through a longer pipe would get the same one.
  • A route that dies mid-conversation recovers too. That is the ordinary state of a phone changing networks, so it is the case that matters most: a connection that stops answering is replaced by a carried one, and the carried one replaces the cached entry, because a route that has just failed is not a route to keep trying first.
  • One request is lost when a route dies, deliberately. A connection that died halfway through sending is not an answer either, but it is still not safe to repeat: the bytes may have arrived and been acted on. So the request in flight is reported to the caller and only the ones after it are carried — whether to send it again is the caller’s decision, because the caller is the only layer that knows whether doing so is harmless.
  • No new trust is needed, for three reasons. A device’s identifier lives inside its attestation, signed by the root key, so a roster means the same thing at every home. A device row’s keys are never rewritten, so the far home reads which device a key belongs to out of its own records rather than the sender’s claim. And registration is anonymous, so the first carried request from an unknown key is the one that creates the row — it passes as nobody, and an authenticated route then refuses it for having no session.

What carrying does not hide: the carrier sees which homes this device has business with, when, and how much. That is the cost of the fallback and the reason it is a fallback rather than the normal road. Always carrying would trade the metadata the other way, and it is a different product decision rather than a missing piece of this one.

What this does not do

  • A room cannot be moved. Its home is in its identifier and that identifier is signed into every commit it will ever make. What exists instead is succession: a new room at the new home with the same people, announced from inside the old one over a channel MLS has already authenticated. It does not need the old home except to say so — every member’s key material lives at their home, and their home is in the attestation in the tree the migrating device already holds. Planned moves are handled completely; a home that vanishes without warning leaves the people still pointed at it untold, and no protocol fixes that from this side.
  • Consent is types, not policy. Within one deployment anybody may claim anybody’s key packages, and the argument is that a claim is a drain rather than a disclosure. Between providers that reasoning stops holding: a stranger’s provider can drain somebody’s packages all day and learn who their devices are while doing it. The request and answer are written; what is not written is the rule for deciding.
  • Franking is an open question, and may be answered no. Reporting abuse to a third party requires cryptographic proof that a message was sent — a property this project has so far declined to offer, and one MIMI requires for interoperability.
  • A credential from another provider is not verified. Signatures are checked; who a key belongs to is only established among peers using this project’s own attested credentials. That is MIMI’s general problem rather than this deployment’s, and the checks are documented at the strength they actually have.

MONEY A GROUP HOLDS

The group wallet, and what signs for it

  • MultisigMultisig
  • BitcoinBitcoin
  • EthereumEthereum
  • TRONTRON
  • SolanaSolana
  • SafeSafe
  • SquadsSquads

A wallet a group holds is the one thing on this list that is not ours. Every chain already has a way for several people to hold one address, and this uses that way rather than inventing a construct on top of it: P2WSH on Bitcoin, Safe on the EVM chains, the account’s own permission system on Tron, Squads V4 on Solana. Four crates under wallet/crates/multisig/, one per chain, behind a single JSON‑RPC entry point where every method is prefixed by the chain it belongs to — bitcoin_*, evm_*, solana_*, tron_*.

What matters for everything else on this page is where the keys are. There is no wallet service and no custody: each signer’s key stays on their own device, the address is built out of all of them at once, and the threshold is the group’s rule rather than a setting on a server. Nothing this deployment runs can move that money, because nothing it runs has ever held a key to it — the same sentence as the one about the delivery service holding ciphertext it cannot read, said about coins instead of messages.

Four steps, and only the last is on a chain: the group fixes the rule, somebody proposes a payment, each signer signs on their own device, and at the threshold it goes out. The three before the last are messages in the conversation the payment was argued about in — which is why a payment here still has its reason attached to it a month later, and a payment from an exchange does not.

The whole of it — the flow drawn out, what each chain calls “several people, one address”, and the deployments this application actually talks to — is a page of its own. It is a product page rather than a reference section, and folding it in here would put a chapter about money in the middle of a document about servers.

THE DAPP BROWSER

A browser inside the app, and pages that ask the wallet to sign

  • EthereumEthereum
  • EIP-1193EIP-1193
  • TRONTRON
  • SolanaSolana
  • MultisigMultisig

Status, so the rest of this is not read as shipped: phases 0 to 4 are written and pass their tests, and no real dapp has been opened with it yet. What the unit tests cannot cover is the part that has to be right — injection timing, the handler round trip, and whether a real bundle recognises the provider.

A web page cannot reach a private key and must not be able to. What it can reach is an object put on window before the page loads, whose every method is a message to something outside the page. So the interesting part of this work is not the WebView. It is the router that decides which of a dapp’s many possible requests are answered from local state, which go to a public node, and which stop and wait for a human.

How a wallet listens to a page

There are two mechanisms and they coexist, because the older one cannot be withdrawn.

HOW A WALLET LISTENS TO A PAGE
How a page and a wallet find each otherThe wallet defines its object on the page before any of the page’s own scripts run, because a page that asks “is there a wallet here?” synchronously would otherwise never see one. It then announces itself, answers the page’s request for announcements, and from that point the page makes requests.THE PAGETHE WALLETwindow.ethereum, BEFORE ANY PAGESCRIPT RUNSeip6963:announceProvider, ON LOADeip6963:requestProvidereip6963:announceProvider, AGAINrequest({ method, params })

Two mechanisms, and they coexist because the old one cannot be withdrawn: a global the page reads the moment it loads, and an event pair that lets several wallets answer at once and the page choose. The dot on each line marks the side being arrived at.

  • The global, read the moment the page loads. A page that asks “is there a wallet here?” synchronously at load reads window.ethereum and gets whatever is there at that instant. Which is why injection has to happen at document start, in the page’s own world, before any page script runs — a provider that appears after the bundle has already asked is a provider that dapp will never see. It is defined with Object.defineProperty and non-configurable, so a page shipping its own shim cannot quietly replace it.
  • The event pair, for when more than one wallet is present. EIP-6963 exists because a single global cannot hold two wallets: whoever wrote it last wins, silently. So a wallet announces itself on load, and announces again every time the page fires eip6963:requestProvider — each announcement carrying a name, an icon and a reverse-DNS id, which is what modern connect modals list. The icon is a data URI, so listing wallets fetches nothing from the network.

Which is also why isMetaMask is false here. Many dapps still branch on it, and setting it true is a lie the page then acts on — the lie every other wallet tells. There is a per-site switch, off by default and labelled as what it is, for pages that hard-gate on the flag. A global default of true would tell every page something untrue whether or not it needed to be told.

What the injected object is, and is not

A thin, boring shim: an EIP-1193 object with the legacy send / sendAsync / enable shims older libraries still call, state cached so the common questions cost no round trip, and events pushed out from the app rather than inferred by the script. chainChanged is emitted after the app has switched, never optimistically.

WHAT THE INJECTED OBJECT IS
The surface the injected provider offers a pageAn EIP-1193 object with the legacy shims older libraries still call, state cached so common questions cost no round trip, events pushed from the app rather than inferred, and the EIP-6963 announcement modern connect modals use.request({ method, params })send, sendAsync, enablechainId, selectedAddressisConnected()connect, disconnectchainChangedaccountsChangedannounceProviderTHE PAGEEIP-1193,THE ASKCACHED, NOROUND TRIPEVENTS,PUSHED INEIP-6963,ANNOUNCED

It validates nothing, decides nothing and holds nothing. A rule that appears in the script and not in the app does not exist — the page shares a JavaScript context with it and can call the handler directly.

Every call becomes one message across the bridge and comes back as an EIP-1193 result or an EIP-1193 error — {code, message, data}, with the standard codes used honestly: 4001 only when a person actually pressed Reject, 4100 when the origin has no accounts granted, 4200 for a method we do not implement, -32602 for malformed parameters.

The script never holds a secret and never makes a decision. If a rule appears there and not in the app, the rule does not exist — the page shares a JavaScript context with it and can call the handler directly.

Four answers, and a whitelist

The classification is a whitelist rather than a blocklist: a method nobody has thought about is refused, never forwarded and never guessed at.

FOUR ANSWERS, AND A WHITELIST
What happens to a request from a pageSome methods are answered from what the app already knows, some stop and wait for a person, some are forwarded read-only to the chain endpoint the wallet already uses, and the rest are refused.eth_accountseth_chainIdeth_requestAccountspersonal_signeth_signTypedData_v4eth_sendTransactioneth_calleth_getLogseth_signeth_sendRawTransactionANYTHING UNLISTEDA REQUEST FROM THEPAGEANSWERED FROMLOCAL STATEONLY AFTER APERSON AGREESFORWARDED,READ-ONLYREFUSED

A method nobody has thought about is refused, never forwarded and never guessed at. The way this class of code fails open is somebody adding a passthrough default, so each refusal has a test of its own.

  • An unconnected origin is told nothing. eth_accounts returns an empty list until the origin has been granted one — that is the specified behaviour rather than a courtesy.
  • Forwarding introduces no second setting. Read-only calls go out over the endpoint already configured for that chain, the same one the balances use, including any override.
  • Two refusals are named rather than merely unlisted. eth_sign signs an opaque 32-byte hash, which is the primitive behind the drain-everything phishing page and which no legitimate dapp needs. eth_sendRawTransaction is publishing, which is the wallet’s job — a page that wants a transaction sent asks for one to be signed. Each refusal has a test of its own, because the way this class of code fails open is somebody adding a passthrough default.

What a person is shown before signing

Everything the wallet signed before this was composed on its own screen. Signing what a stranger asked for is a different activity wearing the same word, so what is known is shown and what is not known is said rather than left out.

WHAT IS SHOWN BEFORE ANYTHING IS SIGNED
The four confirmations, and what each one showsConnecting shows the origin in full and marks a first visit; a message is shown decoded or as hex, never truncated; typed data shows its domain and primary type; a transaction shows its calldata decoded where it can be and says so where it cannot.THE ORIGIN, IN FULLA FIRST VISIT, MARKEDIDN DECODED AND RAWBYTES AS UTF-8, OR AS HEXSIWE, FIELD BY FIELDITS DOMAIN VS THE ORIGINTHE DOMAIN AND ITS TYPEPERMIT, IN A SENTENCETHE CALLDATA, DECODEDUNLIMITED APPROVE, WARNEDAN UNKNOWN CALL, SAID SOBEFORE SIGNINGCONNECTSIGN MESSAGETYPED DATATRANSACTION

What is known is shown; what is not known is said rather than left out. A screen that looks complete and is not is the failure this whole part is arranged against.

  • A sign-in whose domain is not this site is stopped. When a message parses as EIP-4361 its domain is compared with the origin, and a mismatch stops the sheet rather than warning underneath it: that signature is the one that logs somebody into a site they are not on.
  • An unlimited approval is the standard way wallets are drained. If one call is decoded it is this one, and approve at the maximum value is warned about explicitly. Anything not decoded is shown as an unknown call with its selector and its raw data — honest, rather than a friendly summary of something nobody read.
  • The origin is re-read immediately before signing. Not trusted from when the sheet opened, and the request is cancelled if the page navigates or the origin changes while it is open.

Origins are scheme, host and port, computed from the URL the WebView actually committed and never from anything the page says about itself. HTTPS only, plus the loopback for development — a page delivered over plain HTTP can be rewritten in flight by anyone on the path, and this one is being handed an account. A grant and a site’s stored data are two separate things to clear, and the settings say so.

What it deliberately does not do

  • No group wallets, in this version. A multisig address is not an ordinary account: it cannot answer a personal signature at all, and a dapp that assumes it can is a dapp that fails quietly. Connecting one means EIP-1271 and a proposal round through the group. The connect sheet says which account it is offering.
  • No phishing blocklist. Somebody else’s list, fetched at runtime, leaks every site visited to whoever hosts it. What is done instead: the full origin always visible, a first visit marked, international domains shown decoded and raw, and plain HTTP refused outright.
  • No Solana or Tron provider yet, and no tabs. A second chain is a second script and a second method table rather than a rewrite — but shipping three at once triples the surface with keys behind it. One page at a time is a complete browser for this purpose.

WALLETCONNECT

The same signatures, reached from a site across the room

  • WalletConnectWalletConnect
  • TRONTRON
  • SolanaSolana

Status: the signing crate and the naming layer are built and tested; pairing, requests, sessions and the end-to-end test are not written. This section is what is being built toward, and it says which is which.

The dapp browser answers a page inside the app. WalletConnect answers one that is not — a site open on a desktop, paired by scanning a code. Different transport, different session model, the same four signatures at the end of it.

PAIRING WITH A SITE ACROSS THE ROOM
How a WalletConnect session is set upA wc: code is scanned with the camera sheet the app already has. A session proposal arrives naming what is wanted; which addresses would be exposed is chosen rather than assumed, and only then do requests begin arriving.SCAN A wc: CODEA SESSIONPROPOSALWHICH ADDRESSES,CHOSENAPPROVED, OR NOTREQUESTS ARRIVE

The same signatures, reached down a different road: a relay and a pairing code instead of a page inside the app. Which is what connects to a dapp running on a desktop.

The rule that shapes the pairing screen is that a session is a set of chains and accounts, and which addresses are exposed is chosen rather than assumed. A session that can see all of somebody’s chains because that was the easy default is a privacy decision nobody made.

Which chains dapps actually ask for

The protocol is namespace-agnostic — a session is a set of CAIP-2 chains and a wallet advertises whichever it can serve — so the protocol supports everything. The ecosystem does not, and that is what decides the order of work.

WHICH CHAINS DAPPS ACTUALLY ASK FOR
The CAIP-2 namespaces, and their standingRead from the CASA namespace registry. eip155 is what nearly every dapp asks for and solana is second; bip122 is real but rarely requested; tvm has been a draft since 2023 and its own text says there is no central registry of chain ids.eip155WHAT NEARLY EVERY DAPP ASKS FORsolanaREAL, AND SECOND MOST ASKED FORbip122REAL, BUT FEW DAPPS REQUEST ITtvmA DRAFT, AND NO REGISTRY OF CHAIN IDS

The protocol is namespace-agnostic, so it supports everything. The ecosystem does not, and the bar is that ordering rather than a measured share — doing all four at once spends the most effort on the least used.

Tron is where the promise breaks, and not because the wallet could not answer: tvm has been a draft since 2023 and its own text says there is no central registry of chain ids, so what little asks may disagree with us about which chain it means. Doing all four at once spends the most effort on the least used.

Worth recording because it is the kind of thing that survives review: Solana’s devnet and testnet identifiers are two similar base58 strings, and writing them from memory got them the wrong way round. Reading them from the registry is what caught it.

The long pole is the signing, not the SDK

Every signing method this wallet had signed a transfer this app composed. A dapp asks for the opposite shape: arbitrary bytes, a structured document, and a transaction carrying arbitrary calldata. None of those were expressible, so any estimate that starts with the SDK has the schedule backwards.

  • Signing is held to published vectors, never to fixtures. A plausible implementation passes a smoke test and produces a signature that recovers to somebody else’s address. Expected values come from the specifications themselves and from an independent implementation of the same specification — including a Cyrillic message, because an implementation that writes the character count into the EIP-191 prefix passes every ASCII test and is wrong.
  • The package choice changed on measurement. The wallet-side SDK declares Android and iOS only, through a payments module this app has no use for; the two packages that carry the protocol support desktop as well. Building on those is what keeps the desktop layout — and it reversed an assumption made before measuring.
  • A relay sees who pairs with whom. That is a second place data leaves the device unencrypted, and it belongs in settings written in the same voice as the first one rather than in a footnote.

Four things are recorded as refusals now, so that they are not quietly done later: no auto-approval of anything, including “just a signature”; no exposing every address by default; no silent chain switching, because it moves which chain a later transaction lands on; and no half-decoding — an honest “this is a contract call and this wallet cannot read it” beats a friendly summary of something nobody parsed.