How it works
Follow one message end to end: deriving a mailbox key, resolving a name, sealing the payload, writing it to a log, and opening it again on the other side.
This page traces a single message through the whole system. Every step below
corresponds to real code in app/js/crypto.js,
app/js/chain.js and the two contracts.
The complete path
Step 1: one signature becomes a mailbox key
Your wallet's signing key secures your funds and is not usable for encryption. bMail derives a separate X25519 encryption keypair from a single EIP-712 typed-data signature, which your wallet renders in full before you approve it.
// the exact typed data your wallet displays
domain = {
name: "bMail",
version: "1",
salt: keccak256("bMail mailbox key derivation v1")
}
MailboxKey = {
purpose: "Derive my bMail encryption key",
wallet: 0x...,
keyVersion: 1,
warning: "Only sign this on the official bMail app. This signature IS your mailbox key."
}
The signature is never broadcast and never becomes a transaction. It is run through HKDF-SHA256 to produce the private key, which is clamped into a valid X25519 scalar. Because the derivation is deterministic, the same wallet on any device reproduces the same mailbox key without anything being stored or synced.
Anyone who obtains this one signature can read every message ever sealed
to that key version. This is why the warning string is inside the signed
payload where the wallet must display it, and why chainId is
deliberately absent from the domain: one identity spans BSC and opBNB.
Step 2: resolving a name
The client calls lookup(name) on the registry, which returns the
owning wallet, its published X25519 public key, the current key version and the
name epoch in a single call. The epoch matters: it increments on every ownership
change, so a client can detect that a contact quietly changed hands rather than
silently mailing a stranger.
Step 3: sealing
The client builds a payload that commits to its own routing, then encrypts it twice with a freshly generated ephemeral keypair.
payload = {
subject, body, sentAt,
from: // sender wallet
to: // recipient wallet
chainId, mailbox, // binds the envelope to one deployment
skv: // sender key version
}
Committing from, to, chainId and
mailbox inside the sealed payload is what stops a copied envelope
from being replayed at a different recipient or on a different deployment and
appearing authentic. The plaintext is then padded up to a multiple of 256 bytes
so the size leaks less about the content.
Two ciphertexts are produced from the same padded plaintext: one to the recipient's key, one to the sender's own key. The second is what lets you read your own sent mail; without it the sender would be as locked out as anyone else.
Step 4 and 5: the send call
function send(
address to,
bytes calldata envelope,
uint256 inReplyTo,
uint256 maxStamp
) external payable
The contract enforces, in order: the envelope is at most 32 KB, the sender is
registered, the recipient is registered, and the recipient has not blocked the
sender. It then computes what stamp is required. If inReplyTo points
at a live stamp that this recipient paid to reach you, the requirement
drops to zero, which is the rule that makes replying always free.
maxStamp is the caller's ceiling. The recipient can change their
price at any time, so without it a sender could be front-run into paying far more
than intended.
Step 6: the message becomes a log entry
The body is never written to contract storage. It is emitted as the data portion of an event, which is roughly 8 gas per byte against 20,000 gas per 32-byte storage slot. This single decision is what makes gas-only mail affordable rather than theoretical.
event Mail(
address indexed from,
address indexed to,
uint256 indexed mailId,
bytes32 convoId,
uint256 inReplyTo,
uint256 stampValue,
bytes envelope
);
The event is emitted before any stamp settlement occurs, so the message is recorded even if a payout path later behaves unexpectedly. Delivery is never contingent on money moving.
Step 7 and 8: reading
A client subscribes to Mail events filtered on the
to topic for its own address, and on from for its sent
copy. For each envelope it checks the version byte, screens the ephemeral public
key, opens the ciphertext for whichever role it holds, and validates the payload
before showing anything.
Successful decryption proves the ciphertext was not altered. It does not
prove who wrote it. Authorship is currently bound by the chain, since the
from topic is the transaction sender. Everything inside the
payload is treated as untrusted input and validated before display.
What happens if the recipient is not registered
The send reverts with RecipientNotRegistered. A wallet must be in
the registry before it can receive, because there is no published encryption key
to seal against otherwise. There is no way to mail an address into existence.