Docs
/
Protocol

Identity and names

How names are minted, priced, transferred, released and versioned, and the rules that make an accidental or malicious loss of a name hard rather than easy.

A bMail address is a name in MailRegistry bound to a wallet and to an X25519 public key. One wallet holds at most one name, and one name resolves to exactly one wallet.

Name rules

Character set
Lowercase a-z, digits 0-9, and hyphen
Hyphen placement
Not leading, not trailing, not doubled
Canonical form
Lowercase; nameHash keys everything
One per wallet
AlreadyRegistered if the wallet already holds one
Fee basis
Visible length, measured with hyphens removed

Measuring price on the de-hyphenated length is deliberate. Without it, a-b would be a four-character name and escape the premium tier that ab falls into, which is the same two-character brand for practical purposes.

Pricing

Visible lengthCostReasoning
5 and upGas onlyThe ordinary case. The gas-only promise applies to real users.
4price4Short names are scarce; a fee funds the protocol instead of a squatter.
3price3As above, priced higher.
1 to 2RejectedInvalidName

register takes a maxFee ceiling and refunds any overpayment. Both matter: the owner can change prices, so a registration sent without a ceiling could be front-run into paying more than intended.

function register(
  string calldata name,
  bytes32 x25519Pubkey,
  uint256 maxFee
) external payable

The name lifecycle

Available unclaimed Held bound to a wallet Pending transfer 7 day window recipient must accept Release grace 7 days exclusive to the previous owner register() initiateTransfer() acceptTransfer() or expiry, or cancel releaseName() after the grace window, anyone may claim it EVERY ARROW INTO A NEW OWNER BUMPS nameEpoch so a client can tell a contact changed hands
Figure 4. All three windows are seven days: TRANSFER_WINDOW, RELEASE_GRACE and OWNERSHIP_WINDOW.

Transfers are two-step and they expire

A handover needs an action from both wallets. The sender calls initiateTransfer(to); the recipient calls acceptTransfer(name, x25519Pubkey) with their own encryption key. Nothing moves until the second call lands.

Two properties make this safe rather than merely inconvenient:

  • It expires. After seven days the pending transfer is dead. A stale approval cannot be redeemed years later by a wallet that has since been compromised.
  • It can be cancelled. cancelTransfer() revokes it immediately.
Why this shape

A single-call transfer means one malicious signature drains your identity the same way a token approval drains a wallet. Requiring the receiving side to act, and letting the approval lapse, removes that class of attack.

Releasing a name

releaseName() gives up a name but does not immediately throw it to the wolves. For seven days only the previous owner may re-register it. Anyone else attempting to claim it in that window is rejected with NameInGrace(prevOwner, freeAt), which reports who holds the exclusive window and when it ends.

This exists because releasing is easy to do by accident, and a released name is exactly the kind of thing an automated squatter watches for.

Reserved names

The owner can mark names reserved in batches of up to 256. A reserved name cannot be registered, and critically it cannot be acquired through acceptTransfer either. An early version checked reservation only in register, which left the transfer path as a way around it.

Reservation is honest about its limits. If a name is reserved after someone already holds it, the contract emits ReservedWhileRegistered(name, holder) rather than seizing it. An existing holder is never dispossessed.

Confusable detection is advisory only

skeleton() folds visually similar characters so a client can warn that rn resembles m. It is deliberately not enforced on chain. An earlier revision did enforce it and would have permanently blocked ordinary names like mall, fall and ball while still failing to catch every lookalike.

Keys and versions

Your encryption key is separate from your wallet key and can be rotated without losing your name or your history.

keyVersionOf[wallet]
Monotonic. It increments on rotation and is never reset, including on release or transfer.
keyHistory[wallet][v]
Every key the wallet has ever published, so mail sealed to an old version stays readable.
nameEpoch[nameHash]
Increments on every ownership change, so a client can flag that a contact is now a different party.

An early revision stored key history inside the account struct, which meant releasing a name wiped it and a later registration would overwrite version 1 with a different key. Any mail sealed to the original key became permanently unreadable. Both mappings now live outside the struct for that reason.

Public key validation

isValidPubkey rejects a published key if it is zero, has the top bit set, or is one of six known small-order Curve25519 points. Those points force the shared secret to a fixed value, which would let anyone decrypt every message sealed to that key. This is checked when publishing a key and, separately, when opening an incoming envelope's ephemeral key.

bMail is an independent, community-built project. It is not affiliated with, endorsed by, or operated by Binance or BNB Chain.