monad-grpc API guide for client developers
A practical reference for anyone integrating with monad-grpc-server. Covers what you can subscribe to, how filters compose, how the consensus stages work, and the failure modes you need to handle.
The proto source of truth is monad.proto. When this guide and the proto disagree, trust the proto.
1. Surface area
monad-grpc-server exposes a single gRPC service monad.grpc.v1.Monad with three RPCs:
| RPC | Kind | Purpose |
|---|---|---|
Subscribe(stream SubscribeRequest) → stream SubscribeUpdate | bidi streaming | Live event stream. The only RPC you'll spend time on. |
Ping(PingRequest) → PongResponse | unary | Reachability + server wall-clock probe. |
GetVersion(GetVersionRequest) → GetVersionResponse | unary | Server semver, pinned SDK tag, proto version. |
Plus the standard sidecar services:
grpc.health.v1.Health: Kubernetes-style liveness/readiness probe.grpc.reflection.v1.ServerReflection: schema discovery forgrpcurl-class tools.
If the operator configured auth.x_token, every RPC (including health and reflection) requires the metadata header x-token: <value> or you get UNAUTHENTICATED.
2. Quick start
grpcurl
TARGET=127.0.0.1:10000
# List services
grpcurl -plaintext $TARGET list
# All block-stage transitions
grpcurl -plaintext -d '{"block_meta":{"all":{}}}' \
$TARGET monad.grpc.v1.Monad/Subscribe
# All transactions to a specific contract
grpcurl -plaintext \
-d '{"transactions":{"watch":{"to":["0x6f49a8f6213531f31237823046e7d7e4b9b249dc"]}}}' \
$TARGET monad.grpc.v1.Monad/Subscribe
Tonic (Rust)
use monad_grpc_proto::v1::{
monad_client::MonadClient, FilterBlockMeta, SubscribeRequest,
};
use std::collections::HashMap;
use tonic::codec::CompressionEncoding;
use tonic::transport::Channel;
use futures::stream;
let channel = Channel::from_static("http://127.0.0.1:10000")
.connect()
.await?;
let mut client = MonadClient::new(channel)
// Opt into server-side compression see §10.
.accept_compressed(CompressionEncoding::Zstd);
let mut block_meta = HashMap::new();
block_meta.insert("all".to_string(), FilterBlockMeta::default());
let req = SubscribeRequest { block_meta, ..Default::default() };
let response = client.subscribe(stream::iter([req])).await?;
let mut stream = response.into_inner();
while let Some(update) = stream.message().await? {
println!("{update:?}");
}
3. The mental model
Every subscriber sends a SubscribeRequest containing named filters, a map per event-type. Each named filter is independent; the server delivers an event iff any named filter of the matching type matches it.
The server tags every delivered SubscribeUpdate with the names of the filters that matched, in the filters field. Use these names to route on the client side without re-running the match logic.
message SubscribeRequest {
map<string, FilterBlockMeta> block_meta = 1;
Ping ping = 3;
map<string, FilterTransactions> transactions = 4;
map<string, FilterLogs> logs = 5;
map<string, FilterCallFrames> call_frames = 6;
map<string, FilterBlockEnd> block_end = 7;
map<string, FilterStateAccess> state_access = 8;
map<string, FilterBlock> blocks = 9;
optional uint64 from_seqno = 10;
bool correlate = 11;
map<string, FilterTxnRejects> txn_rejects = 12;
map<string, FilterEvmErrors> evm_errors = 13;
}
An empty SubscribeRequest{} is a null subscription, nothing arrives. Add at least one named filter to receive anything.
The client may resend SubscribeRequest mid-stream to update filters. Each resend completely replaces the previous filter set (except from_seqno, which is only honoured on the first request).
4. Filter types and what they deliver
| Subscription field | Constraints | Delivers |
|---|---|---|
block_meta | toggle (empty body, optional stage gate) | UpdateBlockMeta - one per block-stage transition |
transactions | sender / to / function_selector / signature / value_transfers_only | UpdateTxnHeader - top-level transaction headers |
logs | address / topic0..2 | UpdateTxnLog - Solidity event logs |
call_frames | max_depth / call_target / caller | UpdateCallFrame - EVM call frames (CALL/DELEGATECALL/STATICCALL/CREATE) |
state_access | address / storage_key | UpdateAccountAccess + UpdateStorageAccess - per-state-touch events |
block_end | toggle | UpdateBlockEnd - block finalisation summary (eth_block_hash, state_root, receipts_root, logs_bloom, gas_used) |
blocks | min_stage / stages | UpdateBlock - composite snapshot of an entire block at a stage; one delivery per stage transition |
txn_rejects | reason whitelist | UpdateTxnReject - txns rejected by validation before EVM execution |
evm_errors | domain_id / status_code whitelists | UpdateEvmError - EVM execution errors (revert, halt, OOG, invalid opcode) |
Within one named filter: AND across populated fields. Empty repeated field is a wildcard.
Across named filters of the same type: OR. The server returns matching names in filters[].
Across filter types: independent. Subscribing to logs does not give you transactions headers.
Hex format for filter values
| Field semantic | Length | Example |
|---|---|---|
| Address (20 bytes) | 42 chars | 0x6f49a8f6213531f31237823046e7d7e4b9b249dc |
| Hash / topic / storage key (32 bytes) | 66 chars | 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef |
| Function selector (4 bytes) | 10 chars | 0xa9059cbb |
The server normalises mixed-case input to lowercase. Invalid hex (non-hex characters) and wrong-length values are rejected at Subscribe time with INVALID_ARGUMENT and a clear message, this is intentional fail-fast so a typo doesn't silently produce zero matches.
5. Consensus stages Monad's finality model
Every per-event update carries a stage: BlockStage field, and every named filter can gate by it:
PROPOSED → VOTED → FINALIZED → VERIFIED
\
→ REJECTED (reorg / validation failure)
| Stage | Meaning | Latency | Reorg risk |
|---|---|---|---|
PROPOSED | Block produced by leader, not yet voted on | <50ms | high |
VOTED | Block has consensus votes, not yet finalized | ~200-500ms | low |
FINALIZED | Block is canonical (BFT finality reached) | ~800ms | none |
VERIFIED | State root has been verified by a subsequent block | ~1.5s | none |
REJECTED | Explicit reject OR implicit-abandon (sibling won the race) | varies | terminal |
Per-named-filter stage gating
Every Filter* message has two optional stage fields:
| Field | Behaviour |
|---|---|
min_stage: BlockStage | Floor (>=). Drops events whose stage is below this. UNSPECIFIED (default) = no floor. |
stages: repeated BlockStage | Exact-match whitelist (yellowstone-style). When non-empty, only stages in the list pass; min_stage is ignored. |
Defaults (UNSPECIFIED + empty stages) = "deliver every stage". Per-event events are re-emitted at every stage transition, so a default config delivers the same event up to 4 times tagged with different stages, plus REJECTED on implicit-abandon. The composite UpdateBlock behaves the same 4 composites per block.
Common patterns
// Real-time mempool / UI: 1× per event, low latency, may reorg
{"logs": {"realtime": {"address":["0x..."], "stages":["BLOCK_STAGE_PROPOSED"]}}}
// Indexer writing to DB: 1× per event after BFT finality, no reorg risk
{"logs": {"indexer": {"address":["0x..."], "stages":["BLOCK_STAGE_FINALIZED"]}}}
// Bridge / accounting: maximum guarantee (state root verified)
{"logs": {"committed": {"address":["0x..."], "stages":["BLOCK_STAGE_VERIFIED"]}}}
// Multi-tier in one subscription: UI gets PROPOSED, DB gets FINALIZED
// SubscribeUpdate.filters[] tells you which one matched per-event
{
"logs": {
"realtime": {"address":["0x..."], "stages":["BLOCK_STAGE_PROPOSED"]},
"committed": {"address":["0x..."], "min_stage":"BLOCK_STAGE_FINALIZED"}
}
}
min_stage: FINALIZED (a floor, not a whitelist) gives you both FINALIZED and VERIFIED useful when a downstream system can deduplicate by (block_number, log_index).
Stage coverage caveats
txn_rejectsandevm_errorsare emitted only at PROPOSED. The SDK's reassembledExecutedBlockdoes not retain reject/error codes, so we cannot re-emit at later stages. Pair with ablock_meta/transactionsfilter at FINALIZED if you need an accounting-grade boundary.composite UpdateBlock(theblocksmap) is emitted at every stage transition useful for accounting subscribers wanting an atomic block snapshot.
6. Filter messages reference
message FilterTransactions {
repeated string sender = 2; // 20-byte hex
repeated string to = 3; // 20-byte hex
repeated string function_selector = 4; // 4-byte hex (`0x` + 8 chars)
repeated string signature = 5; // 32-byte txn_hash
BlockStage min_stage = 6;
repeated BlockStage stages = 7;
bool value_transfers_only = 8; // server-side: drop value == "0x0"
}
message FilterLogs {
repeated string address = 2;
repeated string topic0 = 3;
repeated string topic1 = 4;
repeated string topic2 = 5;
BlockStage min_stage = 6;
repeated BlockStage stages = 7;
}
message FilterCallFrames {
uint32 max_depth = 2; // 0 = no cap; 1 = top-level only
repeated string call_target= 3;
repeated string caller = 4;
BlockStage min_stage = 5;
repeated BlockStage stages = 6;
}
message FilterStateAccess {
repeated string address = 2;
repeated string storage_key = 3;
BlockStage min_stage = 4;
repeated BlockStage stages = 5;
}
// Toggle filters: empty body = "deliver every event of this type"
message FilterBlockMeta { BlockStage min_stage = 1; repeated BlockStage stages = 2; }
message FilterBlockEnd { BlockStage min_stage = 1; repeated BlockStage stages = 2; }
// Composite block: per-filter stage gate
message FilterBlock { BlockStage min_stage = 1; repeated BlockStage stages = 2; }
// Tier-1 missing-event filters
message FilterTxnRejects {
repeated uint32 reason = 1; // SDK reject codes; empty = wildcard
BlockStage min_stage = 2;
repeated BlockStage stages = 3;
}
message FilterEvmErrors {
repeated uint64 domain_id = 1; // SDK domain enums; empty = wildcard
repeated int64 status_code = 2;
BlockStage min_stage = 3;
repeated BlockStage stages = 4;
}
7. Update messages reference
message UpdateBlockMeta {
uint64 block_number = 1;
string block_id = 2; // consensus block ID, 32-byte hex
string parent_eth_hash = 3; // populated on PROPOSED
uint64 round = 4;
uint64 epoch = 5;
uint64 timestamp_ns = 6; // consensus clock
BlockStage stage = 7;
uint64 stage_changed_at_ns = 8; // server wall clock
uint32 txn_count = 9; // 0 until FINALIZED+
uint64 gas_used = 10; // 0 until FINALIZED+
AbandonReason abandon_reason = 11; // populated only on stage = REJECTED
}
message UpdateTxnHeader {
uint64 block_number; uint64 txn_index; string txn_hash;
string sender; uint32 txn_type;
string chain_id; uint64 nonce; uint64 gas_limit;
string max_fee_per_gas; string max_priority_fee_per_gas;
string value; // U256 hex, "0x0" if no native transfer
string data; // input bytes hex
string to; // empty when is_contract_creation
bool is_contract_creation;
uint32 access_list_count; uint32 auth_list_count;
BlockStage stage;
}
message UpdateTxnLog {
uint64 block_number; uint64 txn_index; uint32 log_index;
string address;
repeated string topics; // 0..4 entries, each 32-byte hex
string data;
BlockStage stage;
}
message UpdateTxnReceipt {
uint64 block_number; uint64 txn_index;
bool status; // EVM success bit
uint64 gas_used; uint32 log_count;
BlockStage stage;
}
message UpdateCallFrame {
uint64 block_number; uint64 txn_index;
uint32 depth; // 0 = top-level entry
string caller; string call_target;
string value; string input; string output;
BlockStage stage;
}
message UpdateBlockEnd {
uint64 block_number;
string eth_block_hash; string state_root; string receipts_root;
string logs_bloom; // 256-byte hex
uint64 gas_used;
BlockStage stage;
}
message UpdateAccountAccess {
uint64 block_number; uint64 txn_index;
bool has_txn_index; // false = block-level prologue/epilogue
string address; string balance; uint64 nonce; string code_hash;
BlockStage stage;
}
message UpdateStorageAccess {
uint64 block_number; uint64 txn_index;
bool has_txn_index;
uint64 account_index; // index into the AccountAccess block of this txn
string key; string value;
BlockStage stage;
}
message UpdateBlock {
uint64 block_number; string block_id; BlockStage stage;
UpdateBlockMeta meta;
UpdateBlockEnd end; // unset on REJECTED
repeated UpdateTxnHeader txn_headers;
repeated UpdateTxnReceipt txn_receipts;
repeated UpdateTxnLog txn_logs;
repeated UpdateCallFrame call_frames;
repeated UpdateAccountAccess account_accesses;
repeated UpdateStorageAccess storage_accesses;
}
message UpdateTxnReject {
uint64 block_number; uint64 txn_index;
uint32 reason; // SDK code; decode via SDK headers
BlockStage stage;
}
message UpdateEvmError {
uint64 block_number; uint64 txn_index;
bool has_txn_index;
uint64 domain_id; int64 status_code; // SDK enums
BlockStage stage;
}
Every SubscribeUpdate carries:
seqno: uint64- monotonic event-ring seqno (use asfrom_seqnocursor for resume).created_at: google.protobuf.Timestamp- when the server constructed the update.filters: repeated string- the names of every named filter that matched this event.- One of the
update_oneofvariants above.
8. Special features
correlate: true - auto-pull related events
When correlate: true is set on the SubscribeRequest, a UpdateTxnHeader matched by any transactions named filter pulls every other event of the same transaction through the gate, even if the per-event filter wouldn't match it. So:
{
"transactions": {"watchlist": {"sender":["0x..."]}},
"correlate": true
}
…delivers UpdateTxnHeader + UpdateTxnReceipt + every UpdateTxnLog / UpdateCallFrame / UpdateAccountAccess / UpdateStorageAccess of those transactions, all tagged with filters: ["watchlist"]. The client routes by name as if every event had matched directly.
The per-block correlation set is bounded and reset on UpdateBlockEnd / UpdateBlockMeta(REJECTED).
Use this when you want atomic per-txn delivery without subscribing to every event type separately.
value_transfers_only: true native ETH transfer monitor
{"transactions": {"transfers": {"value_transfers_only": true}}}
Server delivers only UpdateTxnHeader whose value != "0x0". Catches:
- EOA → EOA transfers
- Contract calls forwarding ETH (
msg.value > 0) - Contract creation with value
UpdateTxnHeader already carries full sender / to / value / nonce / data no separate event type needed. AND-combinable with to: [...] for a wallet watch:
{"transactions": {"my_wallet": {"to":["0x..."], "value_transfers_only": true}}}
from_seqno replay after disconnect
When set on the first SubscribeRequest of a stream, the server replays all retained SubscribeUpdates with seqno > from_seqno before switching to live broadcast.
{"block_meta":{"all":{}}, "from_seqno":"31011280"}
Requires the operator to have set replay_buffer_capacity > 0 on the server. Failure modes:
FAILED_PRECONDITION: from_seqno set but replay is disabledoperator didn't enable replay.FAILED_PRECONDITION: from_seqno X is older than retention; reconnect from seqno Y or later, cursor evicted from the in-memory ring; reconnect from the suggested seqno or accept some gap.
from_seqno is honoured only on the first request of a stream. Subsequent updates with from_seqno are silently ignored by the server (with a log warning).
Heartbeat Ping / Pong
Two flavours:
-
Client-driven Ping inside
SubscribeRequest:{"ping": {"nonce": "42"}}Server replies with a
Pongcarrying the same nonce inside the same stream. -
Server-driven Ping every
subscriptions.ping_interval_secs(default 10s). Subscribers should treat unknownPingas keepalive.
9. Error codes
| gRPC status | When | What to do |
|---|---|---|
UNAUTHENTICATED | Missing / invalid x-token | Provide the token via metadata. |
INVALID_ARGUMENT | Filter rejected: invalid hex, wrong length, exceeds *_max, hits reject list, exceeds max named filters | Read the message, fix the filter. The message names the offending field and value. |
FAILED_PRECONDITION | from_seqno is older than retention OR replay is disabled on the server | Either reconnect without from_seqno (live tail) or use a more recent cursor. |
RESOURCE_EXHAUSTED | Subscriber's outbound buffer overflowed (you're not draining fast enough) | Drain faster; check subscriptions.client_outbound_capacity. |
ABORTED | Server's broadcast ring lagged - your subscriber fell behind by more than broadcast_capacity | Reconnect; if you have a recent seqno, resume via from_seqno. |
The server actively closes the stream after writing the status. The client should treat any of these as "reconnect and resume" except INVALID_ARGUMENT and UNAUTHENTICATED, which require fixing the request.
10. Compression
Standard gRPC negotiation. The server advertises supported codecs via grpc.compression.{accept,send} in its config.
Clients opt in via grpc-accept-encoding: zstd (or gzip). Without that header the stream is uncompressed.
Production indexers typically benefit from enabling zstd (~50% bandwidth reduction on JSON-like proto).
11. Worked examples
A. ERC20 Transfer indexer for one token
// Indexer-grade: only after BFT finality, no reorg
{
"logs": {
"transfers": {
"address": ["0xTOKEN_CONTRACT"],
"topic0": ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"],
"min_stage": "BLOCK_STAGE_FINALIZED"
}
}
}
You'll get each Transfer event twice (FINALIZED + VERIFIED). Dedupe by (block_number, log_index) or use stages: ["BLOCK_STAGE_FINALIZED"] for exactly-once.
B. Wallet balance monitor
{
"transactions": {
"incoming": {"to": ["0xMY_WALLET"], "value_transfers_only": true},
"outgoing": {"sender": ["0xMY_WALLET"]}
}
}
UpdateTxnHeader.filters[] will contain "incoming" or "outgoing"; route accordingly.
C. DEX pool watcher with full execution trace
{
"transactions": {"to_pool": {"to": ["0xPOOL_ADDR"]}},
"correlate": true
}
Every txn to the pool plus its logs, receipts, call frames, and state accesses, atomic per-txn delivery.
D. MEV / mempool monitor real-time + committed in one stream
{
"transactions": {
"early": {"sender":["0xBOT"], "stages":["BLOCK_STAGE_PROPOSED"]},
"committed": {"sender":["0xBOT"], "stages":["BLOCK_STAGE_FINALIZED"]}
}
}
filters field on each delivery tells you whether it's the speculative or committed copy.
E. Reorg detector
{
"block_meta": {"all": {"min_stage": "BLOCK_STAGE_REJECTED"}},
"blocks": {"rejected": {"stages": ["BLOCK_STAGE_REJECTED"]}}
}
UpdateBlockMeta(stage=REJECTED) carries abandon_reason (EXPLICIT_REJECT, IMPLICITLY_ABANDONED, TIMEOUT); UpdateBlock(stage=REJECTED) gives the full rolled-back content so you can revert any state you wrote.
F. Resume after disconnect
// After stream closes with `seqno=12345` as the last successful update:
let req = SubscribeRequest {
from_seqno: Some(12345),
block_meta, // your existing filters
..Default::default()
};
If you get FAILED_PRECONDITION oldest_available is Ntoo far behind, reconnect from N (and accept missing some events) or live-tail.
12. Limits and caveats
- Order between transactions is not guaranteed in raw streams. Within one transaction the server preserves SDK order. Composite
UpdateBlockhas txns sorted bytxn_index. - Events can be lost if the reader (your subscriber) lags too far. The server detects gaps in
seqnoand closes withABORTED. - Speculative blocks can be implicitly abandoned without an explicit reject. Watch
UpdateBlockMeta.abandon_reasonto distinguish. signaturefield onFilterTransactionsmatches againstUpdateTxnHeader.txn_hashonly. Receipts/logs of the same txn requirecorrelate: true.storage_keyfilter onstate_accessonly constrainsUpdateStorageAccess. A pure-address state filter never matches storage updates without a key - by design until a futureaccount_index → addressresolver lands.- Per-deployment
FilterLimitsmay be stricter than defaults. Operators can capmaxnamed filters, list lengths, and reject specific addresses. Read theINVALID_ARGUMENTmessage - it names the specific limit you hit.