Consumer Block Stream
The consumer block stream lets a gateway expose the beacon blocks it already decodes to your own downstream consumers, over WebSocket or gRPC. It is read-only, opt-in, and off by default. Each consumer authenticates with its own short-lived token, separate from the gateway's API key.
Enable it
Add the stream fields to config/app_conf.yml and restart. Only stream_enable is required; the rest have the defaults shown.
stream_enable: true # default: false
stream_only: false # default: false — full gateway (CL + mesh publish)
stream_addr: 127.0.0.1:9600 # default — WebSocket listener (own port, off /metrics)
stream_grpc_addr: 127.0.0.1:9601 # default — gRPC listener
stream_require_auth: true # verify consumer JWTs; false = loopback only
stream_max_conns: 256 # global connection cap
stream_max_conns_per_sub: 8 # per-consumer-key connection cap
stream_buffer_size: 64 # per-connection ring buffer (drop-on-overflow)Set stream_only: true if you only want the stream and do not run a consensus client. The gateway still joins the mesh but does not publish. Because it never starts the CL host, /health reports cl_peers, cl_health and subscribed_topics as skipped and returns 200 on the mesh signals alone (with telemetry_enable: true, which drives the mump2p_health check).
The gateway verifies consumer tokens against the JWKS at your remote_auth_url, so it must point at the same auth service that mints the stream tokens.
Exposure. Listeners default to loopback (
127.0.0.1:9600/127.0.0.1:9601), separate from the/metricsand/healthport. Enabling the stream does not bind every interface. A non-loopback bind is an operator choice and must sit behind a trusted TLS-terminating proxy — the gateway does not terminate TLS. Disabling auth is allowed only on a loopback bind.
Step 1 — mint a stream key (console)
Open the Optimum Partner Console and select the operator you are minting for. Check the network picker (top right) first — the key is minted for the selected chain (Hoodi or Mainnet). Open the API keys section (labelled Manage Gateways for a customer-role login), choose the Stream consumers tab, create a key, name it after the downstream consumer, and copy the value.
- The raw
osc_key is shown once and cannot be retrieved again — copy it before dismissing the dialog. - Mint one key per consumer. Revocation and the per-connection cap are both per key, so a shared key cannot be cut off individually.
- Use a Hoodi key with a Hoodi gateway, and a Mainnet key with a Mainnet gateway. The picker must match the gateway's chain, the same rule as
ogw_live_API keys.
Step 2 — exchange the key for a JWT
The consumer swaps its osc_ key for a JWT. The key is the request body, so there is no Authorization header on this call:
curl -X POST https://auth.getoptimum.io/api/v1/stream/token \
-H "content-type: application/json" \
-d '{"stream_key":"osc_live_..."}'{
"access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6...",
"token_type": "Bearer",
"expires_in": 3600,
"operator_id": "1"
}The access_token is the stream JWT. Decoded, its claims look like this — the gateway checks aud=stream, and caps and revocation key off sub (as_<streamKeyId>). Hoodi example:
{
"aud": "stream",
"iss": "https://auth.getoptimum.io",
"sub": "as_6772872d87e3e0e1f6a6c97630413fa9",
"operator_id": "1",
"chain_id": "560048",
"cluster_ids": ["optimum_ethereum_hoodi_v0_1"],
"iat": 1786709057,
"exp": 1786712657
}A Mainnet token uses chain_id "1" and cluster_ids: ["optimum_ethereum_mainnet_v0_1"] (or the Mainnet cluster ID Optimum assigned you at onboarding).
Tokens are short-lived (default 3600s, ceiling 21600s). A real consumer re-runs this call to refresh before expiry — the same way an OAuth client renews a token. The gateway verifies the signature locally against the published JWKS and never calls back to auth, so revoking a key stops new tokens immediately but an already-issued token keeps working until it expires. Keep the lifetime short so a cutoff takes effect quickly.
Step 3 — open a stream
There are two payload modes:
- metadata (default) — the decoded block event only: slot, proposer, roots, size, source, timing. No block bytes.
- raw — the same event plus the verbatim
ssz_snappyblock bytes as a base64rawfield.
Set the token once, then connect.
TOKEN=$(curl -s -X POST https://auth.getoptimum.io/api/v1/stream/token \
-H "content-type: application/json" \
-d '{"stream_key":"osc_live_..."}' | jq -r .access_token)WebSocket
wscat -H "Authorization: Bearer $TOKEN" \
-c "ws://GATEWAY_HOST:9600/api/v1/stream/blocks?mode=metadata"Each block arrives as one JSON frame (metadata mode):
{
"type": "block",
"slot": 3706300,
"proposer_index": 535778,
"parent_root": "saTBS/MRxOtn3FMP5vZGLhMjcgDYoJo5ISec1nGpt8M=",
"state_root": "HgD2msizU7N6A6663xIsK5LiauwtPVGTY4Gnbc9mfIg=",
"block_size_bytes": 17365,
"topic": "/eth2/c6ecb76c/beacon_block/ssz_snappy",
"source": "mump2p",
"received_at_ms": 1786689003070,
"gateway_id": "ag_...",
"fork_digest": "c6ecb76c",
"stale": false
}For raw mode, use mode=raw; the frame additionally carries the block bytes:
{ "type": "block", "slot": 3706300, "...": "...", "raw": "8gAA...base64 ssz_snappy..." }Browsers cannot set request headers, so pass the token via the Sec-WebSocket-Protocol header instead — offer two values, the marker optimum.stream.v1 and bearer.<jwt>. The server negotiates only the marker and never echoes the token.
gRPC
grpcurl -plaintext \
-import-path proto \
-proto getoptimum/optimum_gateway/service/stream/v1/stream.proto \
-H "authorization: Bearer $TOKEN" \
-d '{"mode":"metadata"}' \
GATEWAY_HOST:9601 \
getoptimum.optimum_gateway.service.stream.v1.BlockStreamService/SubscribeEach block is a block frame:
{
"block": {
"slot": "3706300",
"proposerIndex": "535778",
"parentRoot": "saTBS/MRxOtn3FMP5vZGLhMjcgDYoJo5ISec1nGpt8M=",
"stateRoot": "HgD2msizU7N6A6663xIsK5LiauwtPVGTY4Gnbc9mfIg=",
"blockSizeBytes": "17365",
"topic": "/eth2/c6ecb76c/beacon_block/ssz_snappy",
"source": "mump2p",
"receivedAtMs": "1786689003070",
"gatewayId": "ag_...",
"forkDigest": "c6ecb76c"
}
}The stream is open-ended, so stop it yourself: Ctrl-C for WebSocket, or -max-time <seconds> for gRPC. A trailing gRPC DeadlineExceeded is your own timeout expiring, not a server error.
Lag signal
Each connection has a bounded buffer. If a consumer reads too slowly and the buffer overflows, the gateway drops events and sends a lag frame with the cumulative dropped count, then resumes. A slow consumer never stalls the gateway.
{ "type": "lagged", "dropped": 12 }Over gRPC the same signal is a lagged frame: { "lagged": { "dropped": "12" } }.
Liveness signal
A quiet feed and a broken one look identical over a healthy connection: the transport stays up on its own pings while no blocks arrive. The gateway therefore emits a heartbeat every stream_heartbeat_interval_sec (default 20), whether or not blocks are flowing. Setting it to 0 disables the frame entirely, which puts you back to not being able to tell those two apart.
{ "type": "heartbeat", "last_slot": 3706300, "expected_slot": 3706302, "silence_ms": 24120 }Over gRPC the same signal is a heartbeat frame: { "heartbeat": { "lastSlot": "3706300", "expectedSlot": "3706302", "silenceMs": "24120" } }.
| Field | Meaning |
|---|---|
last_slot | Last slot actually written to your connection; 0 before the first block |
expected_slot | Slot the chain should be on now, derived from the wall clock |
silence_ms | Milliseconds since the last block was written to your connection; 0 before the first |
expected_slot - last_slot is an observation, not a verdict. It also grows for slots the chain legitimately skipped, and it is meaningless before the first block, so correlate it against chain state before concluding anything. A large, sustained difference says the feed you are attached to is behind; it does not say which slots exist, and reconnecting will not recover a gap that happened on the gateway's ingest side.
What the frame does prove is that the connection is alive and which slot was last delivered to it. So alert on heartbeats going missing, which is the failure this frame exists to expose, rather than on the arithmetic alone. For loss on your connection specifically, lagged is the signal.
Holding a stream open for weeks
Streams are meant to stay open indefinitely.
Three obligations fall on the client.
Send transport keepalives. During a quiet stretch nothing between you and the gateway generates traffic, and NAT and conntrack entries expire. The gateway accepts client pings as often as every stream_keepalive_min_time_sec (default 20) and permits them with no active stream. Ping somewhat less often than that, for example every 30s: pings faster than the minimum are answered with GOAWAY too_many_pings. gRPC clients send no keepalives at all unless configured, so this must be set explicitly.
Reconnect on GOAWAY. A gateway restart, or a reload of the TLS terminator in front of it, sends GOAWAY. Reconnect, and account for the gap.
Retry Internal, not only Unavailable. A stream cut after its first block ends as Internal, because response headers are already sent and a reset is all that remains; cut before the first block it ends as Unavailable. Most gRPC retry policies treat Internal as non-retryable, so a default configuration will not reconnect you.
Gaps are permanent
Nothing is buffered across connections and nothing is ever replayed, so every reconnect is a permanent hole. Detect holes rather than assuming their absence.
But a gap in the slot sequence is not by itself evidence of loss: slots the network left empty are normal and produce no block at all. A jump from 100 to 102 may mean slot 101 was missed, or that nobody proposed it. Reconcile against your own beacon node or an archive to tell the two apart. A lagged frame is the one signal that says this connection definitely dropped events.
One event per observation, so deduplicate deliberately
The stream carries one event per source observation, by design: the same block seen over both libp2p and mump2p arrives twice, distinguished by source. Those two are not redundant, they are the cross-path comparison, and collapsing them throws away the per-path arrival timing.
Same-source repeats used to reach consumers as well: one slot was measured arriving twice over mump2p 9ms apart, with block_size_bytes differing between the two (35053 vs 35047) because the block had been re-encoded. The gateway now collapses those at the source on (source, slot, proposer_index, state_root), so you should not normally see them. Do not treat that as absolute: the window is 30 seconds and the state is per-process, so a gateway restart or a very late repeat can still let one through.
Pick the key for what you are counting:
| You want | Key on |
|---|---|
| Unique blocks | (slot, proposer_index, state_root) |
| Per-path observations | (slot, proposer_index, state_root, source) |
state_root belongs in both. A proposer can equivocate and publish two genuinely different blocks for one slot, so (slot, proposer_index) alone is not a block identity and would silently discard the second one.
Never key on block_size_bytes or received_at_ms. Both are per-observation and legitimately differ between paths, so they are not stable identity.
Refreshing the token in-band
A stream held for weeks outlives its JWT. The gRPC request stream stays open for exactly this: send another SubscribeRequest carrying only a token at any time and the connection adopts it. Over WebSocket, send the same as a text frame.
{ "token": "eyJhbGciOi..." }Refresh well before exp; every 45 minutes for a one-hour token is ample. A refresh that fails to verify is counted and ignored, leaving the previous token in force, so a malformed refresh cannot sever a working stream.
stream_reauth_mode decides what happens when the token a connection last presented stops verifying:
| Mode | Behavior |
|---|---|
off | Never re-verified; a connection outlives its token indefinitely |
observe (default) | Re-verified every stream_reauth_interval_sec; failures counted, stream kept |
enforce | Failures close the stream with Unauthenticated |
observe ships as the default so streams are measured before anything is cut. Build for enforce: implement refresh now.
grpcurl -d '{"mode":"..."}'sends one message and half-closes, so it cannot refresh. That is a supported shape for a short session, and it is also how clients built against the previous server-streaming signature behave.
Errors
Connection-time (gateway WebSocket / gRPC):
| Condition | WebSocket | gRPC |
|---|---|---|
| Missing / bad token | 401 Unauthorized | Unauthenticated |
Invalid mode | 400 Bad Request | InvalidArgument |
| Connection cap reached | 503 Service Unavailable | ResourceExhausted |
Mid-stream:
| Condition | WebSocket | gRPC |
|---|---|---|
Presented token stopped verifying, stream_reauth_mode: enforce | close 1008, reason token expired, refresh required | Unauthenticated |
| Gateway or terminator restarted | close | GOAWAY, then Unavailable |
| Stream cut after the first block | close | Internal |
Token exchange (POST /api/v1/stream/token on the auth service) is a different call. Unknown, suspended, or revoked osc_ keys fail there as 401 invalid_key — the gateway never sees that status on the stream.
Metrics
Consumer stream series are exported on the telemetry port (telemetry_port, default 48123) when stream_enable is true. See Metrics — Consumer Block Stream for names and types (mump2p_stream_*).
curl -s http://localhost:48123/metrics | grep mump2p_stream_Availability
The stream is live on Hoodi and Mainnet. Mint the consumer key in the Partner Console with the matching network selected, and exchange it at https://auth.getoptimum.io/api/v1/stream/token.
| Network | Console picker | Cluster ID (typical) |
|---|---|---|
| Hoodi | Hoodi | optimum_ethereum_hoodi_v0_1 |
| Mainnet | Mainnet | optimum_ethereum_mainnet_v0_1 |
Use the cluster ID Optimum assigned you at onboarding if it differs from the table.

