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 — skip CL; never publishes (requires stream_enable)
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" } }.

Errors

Connection-time (gateway WebSocket / gRPC):

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

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.