Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

mail-grpc: the chain gateway

Standard port(s): gRPC 50051, health 8193 (both in-code defaults, loopback-bound).

The gRPC gateway other servers use to reach Solana — it implements the SolanaMail service’s full RPC surface. MX/ mail servers call this instead of talking to Solana directly. See Route index below for the complete method list.

Route index

Every RPC the SolanaMail service answers (mail_api/protos/sithbit.proto), grouped by area. Authentication is the transport’s job, not any RPC’s — see Authenticating services below.

Alias directory

RPCRequest → ResponsePurpose
ResolveAliasAliasRequest → AliasResponseresolve an email local-part alias to a wallet address
ListAliasesListAliasesRequest → ListAliasesResponselist every alias local-part pointing at a wallet, off the alias indexer

Mailbox and postage

RPCRequest → ResponsePurpose
GetMailboxMailboxRequest → MailboxResponseread a mailbox’s mail count, default postage, and no_ipfs flag
GetMailboxKeyMailboxKeyRequest → MailboxKeyResponsefetch a wallet’s published encryption key (X25519/RSA/none) plus no_ipfs
GetFromboxFromboxRequest → FromboxResponselook up a (from, to) frombox: required postage, stamp count, stamp fee terms

Sending and receiving mail

RPCRequest → ResponsePurpose
SendMailSendMailRequest → SendMailResponsesubmit a mail send on-chain, with an optional reply-bounty escrow
DeleteMailDeleteMailRequest → DeleteMailResponsedelete a received message on-chain
RefundMailRefundMailRequest → RefundMailResponserefund a received message back to its sender
FindMessageFindMessageRequest → FindMessageResponselocate a landed message by IPFS CID — a dedupe check after a lost SendMail response

Reply bounties

RPCRequest → ResponsePurpose
ClaimBountyClaimBountyRequest → ClaimBountyResponseclaim an escrowed reply bounty using a reply message as evidence
RefundBountyRefundBountyRequest → RefundBountyResponsereclaim an expired, unclaimed reply bounty

Transactions

RPCRequest → ResponsePurpose
GetTransactionStatusTransactionStatusRequest → TransactionStatusResponsepoll the commitment status of a previously submitted transaction

Domains

RPCRequest → ResponsePurpose
GetMailDomainMailDomainRequest → MailDomainResponsecheck a MailDomain account: exists, active, authority, gateway_is_authority
ListAuthoritativeDomainsListAuthoritativeDomainsRequest → ListAuthoritativeDomainsResponselist active domains whose on-chain authority is this gateway’s own signing key
GetSenderAttestationSenderAttestationRequest → SenderAttestationResponsecheck whether a domain has attested a sender wallet as legitimate

Marketplace

RPCRequest → ResponsePurpose
BrowseListingsBrowseListingsRequest → BrowseListingsResponsebrowse open marketplace listings (aliases and domains for sale)
ListSalesListSalesRequest → ListSalesResponseread the marketplace sales ledger (by wallet, name+kind, or newest overall)
ListParticipantsListParticipantsRequest → ListParticipantsResponsesearch on-chain participant opt-in beacons by tag bitmap filter

Reputation and pinning

RPCRequest → ResponsePurpose
GetSenderReputationSenderReputationRequest → SenderReputationResponselook up a wallet’s cumulative stamp-spend reputation and resulting price-rate bps
GetPinLeasePinLeaseRequest → PinLeaseResponselook up IPFS pinning-lease deposits/count on a CID

When you need it: always, in any deployment where mail should actually reach the chain — without it, sithbitd runs with its chain pipeline disabled.

Network posture: anyone the gateway serves can sign with its keypair, so on any address but loopback the gRPC surface requires mutual TLS — a non-loopback bind_addr without a complete [auth] section refuses to start, and every caller is authenticated by the Ed25519 key in its client certificate. Loopback with no [auth] serves unauthenticated. Keep the gateway on loopback or a private interface, and follow Authenticating services before it is reachable from anywhere else. Why the gateway is a separate private service at all is recorded in the topology design note.

Quickstart:

cargo run -p mail-grpc

Run it as an OS service with mail-grpc service install — the shared subcommand is documented in Running as an OS service.

Config file mail_grpc.toml, or point MAIL_GRPC_CONFIG at an alternate path — the same layered TOML/env mechanism as every other SithBit binary (in-code default → TOML → ./.env → ./.env.$APP_ENV → environment; MAIL_GRPC_* variables override individual settings, e.g. MAIL_GRPC_BIND_ADDR or MAIL_GRPC_ALIAS_INDEX__DATABASE). An empty or missing config file is a runnable dev gateway: it binds 127.0.0.1:50051 — the private-network posture above is the default the code enforces now, not a convention — and resolves the chain endpoint and the signing keypair from the operator’s Solana CLI config (~/.config/solana/cli/config.yml; a missing file means the stock CLI defaults, i.e. mainnet-beta), exactly like the sithbit CLI, so solana config set governs a zero-config gateway. As with the CLI, a bare JSON_RPC_URL environment variable overrides the endpoint (it wins over a configured json_rpc_url), which is handy for pointing a gateway at a local validator without editing config. The annotated mail_grpc/mail_grpc.example.toml documents every key with its default; the Configuration reference has the key/default table.

Migration (clean break): mail-grpc no longer reads its legacy environment-only configuration. GRPC_SERVER_ADDRESS, DEFAULT_KEYPAIR (and DEFAULT_KEYPAIR_VAULT_URI / DEFAULT_KEYPAIR_SECRET_NAME), ALIAS_INDEX_DB, ALIAS_INDEX_POLL_SECONDS, ALIAS_CACHE_SECONDS, and HEALTH_BIND are all ignored. The one legacy name still honored is JSON_RPC_URL (bare, un-prefixed), for parity with the sithbit CLI and the standard Solana convention — it overrides the configured json_rpc_url. In particular, there is no keypair-content-in-an-environment-variable shape anymore: DEFAULT_KEYPAIR used to hold the raw JSON keypair array itself, while the TOML keypair names a key source — a keypair file path, or a cloud secret-manager secret. The nearest env-var equivalent is MAIL_GRPC_KEYPAIR=/path/to/id.json.

Signing keypair: a file or a cloud secret manager

keypair is a key source like the workspace’s other file-loaded secrets. A bare string is a Solana keypair file; the table form fetches the keypair from a cloud secret manager instead — the secret’s value holds exactly what the file would, the raw JSON keypair array. Authentication is each cloud’s ambient chain (Azure managed identity, the AWS credential chain, Google ADC): no new auth to configure, and no key material in the config file — only the secret’s coordinates.

keypair = "signer.json"
keypair = { kind = "akv", vault_uri = "https://<vault>.vault.azure.net/",
            secret_name = "signer" }
keypair = { kind = "asm", secret_id = "sithbit/signer" }
keypair = { kind = "gsm", project = "my-project", secret = "signer" }

Unset (the default), the keypair comes from the Solana CLI config’s keypair_path — ~/.config/solana/id.json unless solana config set moved it.

mail-grpc also runs the alias indexer that backs Aliases’s ListAliases lookups ([alias_index]; an explicitly empty database disables it). See the Configuration reference for the full key/default table.

Authenticating services: a step-by-step guide

Summary. Every process that calls mail-grpc from anywhere but the gateway host’s own loopback must present an Ed25519 client certificate whose public key the gateway lists in [auth] authorized_keys, and must pin the gateway’s own Ed25519 key. There is no certificate authority on either side: the key is the identity. Setting it up is five steps — mint a keypair and certificate for each calling process, mint one for the gateway, write the gateway’s [auth] section, write each caller’s TLS table, and read the two log lines that tell you it worked.

Introduction: why the transport authenticates

The gateway signs and pays for on-chain writes with its own fee-payer wallet on behalf of whoever calls it, so “who may call it” decides who may spend that wallet (the topology note records why the gateway is a separate service and how this posture was reached). Three rules in the gateway’s config validation make the transport, not the network, answer that question:

  • A reachable bind requires a complete [auth] section. Naming any bind_addr other than loopback without cert, key and authorized_keys refuses startup with a message that says the fee-payer wallet would otherwise be handed to the network (mail_grpc/src/config.rs, GrpcConfig::validate). There is no allow_insecure_bind opt-out here, unlike the mail listeners.
  • A half-written [auth] is refused, not ignored. Any one of the three settings present without the other two fails startup naming the missing ones (mail_grpc/src/config.rs, AuthConfig::validate), so a typo can never quietly serve unauthenticated.
  • A present [auth] is enforced on every bind, loopback included (mail_grpc/src/config.rs, the GrpcConfig::validate doc). The loopback exemption keys off the absence of the section, which is what keeps a zero-config dev gateway runnable and lets the test suites exercise the authenticated path on an ephemeral loopback port.

At accept time the gateway’s listener handshakes every socket with an acceptor that requires a client certificate, reads the Ed25519 key out of that certificate’s SubjectPublicKeyInfo, checks it against the decoded allow-list, and hands only an authorized stream to the gRPC server — an unknown caller is dropped before HTTP/2 starts, let alone a handler (mail_grpc/src/listener.rs, Authorizer::admit). The authorized key rides every request as a CallerInfo extension so a handler can attribute the call to the process that made it (mail_grpc/src/listener.rs, CallerInfo). On the caller’s side the same primitive is applied in reverse: the client accepts exactly one server certificate, the one whose Ed25519 key equals gateway_key, ignoring chain, validity dates and server name (server_common/src/grpc_client.rs, PinnedServerVerifier).

That is why every certificate in this scheme is a self-signed Ed25519 leaf, and why the 32-byte key is written in base58 — it is the same transport identity the MX servers bind for SASL EXTERNAL and the same primitive as a wallet address (mail_grpc/src/config.rs, the AuthConfig doc). It is also why the recommended way to mint one is the sithbit CLI’s wallet-certificate command rather than a CA workflow.

Step 1: mint a keypair and certificate for each calling process

Give every calling process — sithbitd, a standalone smtp-server, account-api — its own keypair, so that revocation and attribution are per process (the four protocols inside one sithbitd share one channel and therefore one key). Use a dedicated keypair for this: it is a transport identity, never funded and never the gateway’s fee payer.

solana-keygen new --no-bip39-passphrase -o sithbitd-client.json
sithbit mailbox create-cert --keypair sithbitd-client.json --out sithbitd-client
solana-keygen pubkey sithbitd-client.json

create-cert builds a self-signed Ed25519 certificate whose public key is the keypair’s public key and writes sithbitd-client.crt (the certificate, PEM), sithbitd-client.key (its PKCS#8 private key, PEM, written owner-only) and sithbitd-client.p12 (a combined bundle mail apps import — not needed here; delete it or keep it owner-only, it embeds the private key) (mail_client/src/commands/mailbox/client_cert.rs, ClientCert::invoke). The third command prints the base58 public key: that string is the process’s entry in the gateway’s authorized_keys. Because the certificate’s key is the keypair’s key, you never need to parse a certificate to learn the value the gateway wants.

Any other tool that produces a self-signed Ed25519 certificate works too — openssl req -x509 -newkey ed25519 -keyout key.pem -out cert.pem -nodes -subj "/CN=sithbitd" is the one-liner — but then the base58 key has to be discovered another way; the easiest is the gateway’s own refusal log, which prints the key a rejected caller presented (Step 5).

Step 2: mint the gateway’s own certificate

Repeat Step 1 once for the gateway, with a keypair of its own:

solana-keygen new --no-bip39-passphrase -o mail-grpc-tls.json
sithbit mailbox create-cert --keypair mail-grpc-tls.json --out mail-grpc-tls
solana-keygen pubkey mail-grpc-tls.json

The printed key is what every caller pins as its gateway_key. The gateway’s certificate must be Ed25519 for the same reason the callers’ must: a caller pins the key, not a CA, and its verifier refuses a non-Ed25519 certificate with the same error as a wrong key (server_common/src/grpc_client.rs, PinnedServerVerifier). Keep this keypair separate from the gateway’s signing keypair (the fee payer); the two have nothing in common but the algorithm.

Step 3: configure the gateway

bind_addr = "10.0.1.5:50051"

[auth]
cert = "mail-grpc-tls.crt"
key  = "mail-grpc-tls.key"
authorized_keys = [
  "<sithbitd-client public key>",
  "<account-api-client public key>",
]

cert and key are key sources: a bare string reads a PEM file, and the table form fetches the PEM from a cloud secret manager (kind = "akv", "asm" or "gsm", the same shapes keypair takes) so no key material lives in the config file. Every row is in the configuration reference. Start the gateway; a wrong or incomplete section is refused at startup with the messages described in the introduction, and a good one logs the listen address as before.

Step 4: configure each caller

Every caller takes the same three settings — cert, key, gateway_key — all or none, and its endpoint becomes https://. The client refuses to start on an https:// endpoint with no TLS table and on an http:// endpoint with one, so the config cannot describe a connection that can never work (server_common/src/grpc_client.rs, check_scheme); and gateway_key must decode to exactly 32 bytes of base58 or the client refuses it naming the value (server_common/src/grpc_client.rs, GrpcClientTls::gateway_key).

sithbitd, in sithbitd.toml (reference):

[grpc]
endpoint = "https://10.0.1.5:50051"

[grpc.tls]
cert = "sithbitd-client.crt"
key  = "sithbitd-client.key"
gateway_key = "<mail-grpc-tls public key>"

A standalone smtp-server, in smtp_server.toml (reference):

grpc_endpoint = "https://10.0.1.5:50051"

[grpc_tls]
cert = "smtp-client.crt"
key  = "smtp-client.key"
gateway_key = "<mail-grpc-tls public key>"

account-api, in account_api.toml (reference):

[chain]
grpc_endpoint = "https://10.0.1.5:50051"

[chain.grpc_tls]
cert = "account-api-client.crt"
key  = "account-api-client.key"
gateway_key = "<mail-grpc-tls public key>"

cert and key are key sources here too. sithbit-console has no such table and reaches only an unauthenticated loopback gateway. The caller’s half is an operator obligation: a caller cannot see the gateway’s configuration, so nothing on its side warns when the table is missing — the symptom is the handshake failing at the gateway (Step 5).

Step 5: verify from the logs

Start the callers and watch the gateway’s log. Each connection ends in exactly one of four lines (mail_grpc/src/listener.rs, Authorizer::admit):

Gateway log lineMeaning
gateway caller authorized (debug, with key=)the caller’s key is in authorized_keys; the stream was handed to gRPC
gateway caller not in authorized_keys (warn, with key=)the handshake succeeded but the presented Ed25519 key is not listed — the printed base58 value is the string to add, which is how you recover a key you minted with a tool other than sithbit
gateway caller presented a non-Ed25519 certificate (warn)the caller’s certificate is RSA/ECDSA; mint an Ed25519 one
gateway mTLS handshake failed (warn, with the error)no client certificate at all, or a TLS-level failure — the usual cause is a caller still dialing http:// or missing its TLS table

Two refusals happen on the caller instead: a gateway_key that does not match the certificate the gateway presented fails the handshake on the client side (the pinned verifier), and the scheme/table mismatches from Step 4 refuse the caller’s own startup. The gateway’s health endpoint is unaffected by [auth] and keeps answering on its own port.

Rotation, revocation and what this does not change

  • Revoking one process is removing its key from authorized_keys and restarting the gateway; the other callers are untouched, which is the point of one keypair per process.
  • Rotating the gateway’s certificate changes the key every caller pins, so every caller’s gateway_key must change with it — plan the two edits together, since a caller holding the old pin fails its next handshake.
  • The guardrails stay in force for authorized callers: max_bounty_lamports and fee_payer_floor_lamports still bound what a compromised-but-authorized process could cost (the topology note).
  • Reachability still matters. Keep the gateway on a private segment as the iac/ templates do; [auth] decides who is served, the network narrows who can try.