Skip to content

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.

mintsPOST/stream/tokenissuesBearer tokenAuthorization headerconnectvalidinvalidOnboardingPartner Consoleconsole.getoptimum.ioper-gateway scopeCredentialosc_ keystream scopeAuth serviceauthverifies osc_ keysigns stream JWTTokenStream JWTaud=stream · ~1hConsumerClient connectsWS :9600gRPC :9601GatewayOptimum GatewayReads Authorization headerVerifies signature via JWKSChecks aud=stream · expAdmits or rejects the streamAllowedBlock streamblocks · blobsRejected401Unauthenticated

Enable it

Add the stream fields to config/app_conf.yml and restart. Only stream_enable is required; the rest have the defaults shown.

yaml
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 /metrics and /health port. 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:

bash
curl -X POST https://auth.getoptimum.io/api/v1/stream/token \
  -H "content-type: application/json" \
  -d '{"stream_key":"osc_live_..."}'
json
{
  "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:

json
{
  "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_snappy block bytes as a base64 raw field.

Set the token once, then connect.

bash
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

bash
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):

json
{
  "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:

json
{ "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

bash
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/Subscribe

Each block is a block frame:

json
{
  "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.

json
{ "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.

json
{ "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" } }.

FieldMeaning
last_slotLast slot actually written to your connection; 0 before the first block
expected_slotSlot the chain should be on now, derived from the wall clock
silence_msMilliseconds 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.

Long-running stream lifecycleConsumerStream consumerWS :9600gRPC :9601GatewayOptimum GatewayBlock stream listenerVerifies stream JWT1 · ConnectBearer stream JWT · WS or gRPCBlocksbeacon blocks · blobsHeartbeatsevery ~20s · alert if missingKeepalive pings~every 30s · NAT / conntrack2 · same connection stays open (hours / weeks)alert on missing heartbeats — not on quiet slots↻ repeats3 · In-band token refresh{ "token": "eyJ…" } · before exp · no reconnect requiredGOAWAYgateway restart or TLS reload4 · Reconnectnew Bearer JWT · gap not replayedNothing is buffered across connections — detect gaps, do not assume replayrepeat for weeks

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 wantKey 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.

json
{ "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:

ModeBehavior
offNever re-verified; a connection outlives its token indefinitely
observe (default)Re-verified every stream_reauth_interval_sec; failures counted, stream kept
enforceFailures 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):

ConditionWebSocketgRPC
Missing / bad token401 UnauthorizedUnauthenticated
Invalid mode400 Bad RequestInvalidArgument
Connection cap reached503 Service UnavailableResourceExhausted

Mid-stream:

ConditionWebSocketgRPC
Presented token stopped verifying, stream_reauth_mode: enforceclose 1008, reason token expired, refresh requiredUnauthenticated
Gateway or terminator restartedcloseGOAWAY, then Unavailable
Stream cut after the first blockcloseInternal

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_*).

bash
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.

NetworkConsole pickerCluster ID (typical)
HoodiHoodioptimum_ethereum_hoodi_v0_1
MainnetMainnetoptimum_ethereum_mainnet_v0_1

Use the cluster ID Optimum assigned you at onboarding if it differs from the table.