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:

RPCKindPurpose
Subscribe(stream SubscribeRequest) → stream SubscribeUpdatebidi streamingLive event stream. The only RPC you'll spend time on.
Ping(PingRequest) → PongResponseunaryReachability + server wall-clock probe.
GetVersion(GetVersionRequest) → GetVersionResponseunaryServer 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 for grpcurl-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 fieldConstraintsDelivers
block_metatoggle (empty body, optional stage gate)UpdateBlockMeta - one per block-stage transition
transactionssender / to / function_selector / signature / value_transfers_onlyUpdateTxnHeader - top-level transaction headers
logsaddress / topic0..2UpdateTxnLog - Solidity event logs
call_framesmax_depth / call_target / callerUpdateCallFrame - EVM call frames (CALL/DELEGATECALL/STATICCALL/CREATE)
state_accessaddress / storage_keyUpdateAccountAccess + UpdateStorageAccess - per-state-touch events
block_endtoggleUpdateBlockEnd - block finalisation summary (eth_block_hash, state_root, receipts_root, logs_bloom, gas_used)
blocksmin_stage / stagesUpdateBlock - composite snapshot of an entire block at a stage; one delivery per stage transition
txn_rejectsreason whitelistUpdateTxnReject - txns rejected by validation before EVM execution
evm_errorsdomain_id / status_code whitelistsUpdateEvmError - 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 semanticLengthExample
Address (20 bytes)42 chars0x6f49a8f6213531f31237823046e7d7e4b9b249dc
Hash / topic / storage key (32 bytes)66 chars0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
Function selector (4 bytes)10 chars0xa9059cbb

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)
StageMeaningLatencyReorg risk
PROPOSEDBlock produced by leader, not yet voted on<50mshigh
VOTEDBlock has consensus votes, not yet finalized~200-500mslow
FINALIZEDBlock is canonical (BFT finality reached)~800msnone
VERIFIEDState root has been verified by a subsequent block~1.5snone
REJECTEDExplicit reject OR implicit-abandon (sibling won the race)variesterminal

Per-named-filter stage gating

Every Filter* message has two optional stage fields:

FieldBehaviour
min_stage: BlockStageFloor (>=). Drops events whose stage is below this. UNSPECIFIED (default) = no floor.
stages: repeated BlockStageExact-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_rejects and evm_errors are emitted only at PROPOSED. The SDK's reassembled ExecutedBlock does not retain reject/error codes, so we cannot re-emit at later stages. Pair with a block_meta/transactions filter at FINALIZED if you need an accounting-grade boundary.
  • composite UpdateBlock (the blocks map) 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 as from_seqno cursor 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_oneof variants 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 disabled operator 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:

  1. Client-driven Ping inside SubscribeRequest:

    {"ping": {"nonce": "42"}}
    

    Server replies with a Pong carrying the same nonce inside the same stream.

  2. Server-driven Ping every subscriptions.ping_interval_secs (default 10s). Subscribers should treat unknown Ping as keepalive.


9. Error codes

gRPC statusWhenWhat to do
UNAUTHENTICATEDMissing / invalid x-tokenProvide the token via metadata.
INVALID_ARGUMENTFilter rejected: invalid hex, wrong length, exceeds *_max, hits reject list, exceeds max named filtersRead the message, fix the filter. The message names the offending field and value.
FAILED_PRECONDITIONfrom_seqno is older than retention OR replay is disabled on the serverEither reconnect without from_seqno (live tail) or use a more recent cursor.
RESOURCE_EXHAUSTEDSubscriber's outbound buffer overflowed (you're not draining fast enough)Drain faster; check subscriptions.client_outbound_capacity.
ABORTEDServer's broadcast ring lagged - your subscriber fell behind by more than broadcast_capacityReconnect; 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 UpdateBlock has txns sorted by txn_index.
  • Events can be lost if the reader (your subscriber) lags too far. The server detects gaps in seqno and closes with ABORTED.
  • Speculative blocks can be implicitly abandoned without an explicit reject. Watch UpdateBlockMeta.abandon_reason to distinguish.
  • signature field on FilterTransactions matches against UpdateTxnHeader.txn_hash only. Receipts/logs of the same txn require correlate: true.
  • storage_key filter on state_access only constrains UpdateStorageAccess. A pure-address state filter never matches storage updates without a key - by design until a future account_index → address resolver lands.
  • Per-deployment FilterLimits may be stricter than defaults. Operators can cap max named filters, list lengths, and reject specific addresses. Read the INVALID_ARGUMENT message - it names the specific limit you hit.