> ## Documentation Index
> Fetch the complete documentation index at: https://docs.uniblock.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Solana gRPC streaming through Uniblock

> Yellowstone-compatible Solana gRPC streaming: connect with an x-token, apply server-side filters, and replay up to 48 hours of history.

Solana gRPC streaming carries account, transaction, slot, entry and block metadata updates over one long-lived connection. The wire schema is stock Yellowstone, also called Dragon's Mouth, at proto 12.5. There are no custom extensions, no added fields and no added methods, so any standard Yellowstone client connects unchanged and there is nothing proprietary to install.

## Access

Solana gRPC streaming is available as an add-on. Onboarding runs through the Uniblock team: provisioning is handled per customer, and the endpoint and credential are issued during setup.

<Card title="Contact sales" icon="calendar" href="https://meetings-na3.hubspot.com/thomas-harvie?uuid=31c20777-eaa4-4110-b89f-644f7b64deeb">
  Onboarding for the Solana gRPC add-on runs through the Uniblock team.
</Card>

## Pricing

Solana gRPC streaming is billed on data transferred, at \$50 USD per TB, with partial terabytes rounded up. Enterprise discounts are available.

Replayed data is metered the same as live data, so a full-window catch-up bills like the equivalent live stream. Narrow filters and `accounts_data_slice` are the levers that reduce transferred volume.

## Connect

### Endpoint

```plaintext theme={null}
https://solana-grpc.uniblock.dev
```

TCP 443, HTTP/2. One hostname serves both live and historical subscriptions.

### Authentication

<Warning>
  **Solana gRPC does not use the Uniblock API key.** Streaming traffic does not pass through the Uniblock HTTP gateway, and the credential is a dedicated token issued during onboarding. The `X-API-Key` header that authenticates the Unified API, the Unified JSON-RPC endpoint and the Direct APIs has no effect on this endpoint.
</Warning>

The credential is a single bearer token carried in gRPC metadata under `x-token`. There is no mTLS, no basic auth, no signing step and no handshake exchange. Authorization is evaluated once, when the stream is established, and is not re-checked while a stream runs. The token is opaque, with no meaningful internal structure.

An IP allow-list is available and is configured during onboarding. It is empty by default, so a token authenticates from any address unless addresses are added.

### TLS

TLS only, on 443, against a public CA chain. No client certificate and no mTLS. The system trust store is sufficient.

```rust theme={null}
GeyserGrpcClient::build_from_shared("https://solana-grpc.uniblock.dev")?
    .x_token(Some("<token>"))?
    .tls_config(ClientTlsConfig::new().with_native_roots())?
    .connect().await?
```

### Stubs and client libraries

Stubs are generated from the upstream Yellowstone repository at [github.com/rpcpool/yellowstone-grpc](https://github.com/rpcpool/yellowstone-grpc). Uniblock distributes no `.proto` of its own.

| File                                   | Path in the upstream repository                     |
| -------------------------------------- | --------------------------------------------------- |
| Service and streaming messages         | `yellowstone-grpc-proto/proto/geyser.proto`         |
| Transaction and block types it imports | `yellowstone-grpc-proto/proto/solana-storage.proto` |

`geyser.proto` declares `package geyser` and imports `solana-storage.proto` publicly, so generating from `geyser.proto` alone pulls in both. Stubs generated from the earlier v11 protos are wire-identical across all 26 streaming messages and remain compatible.

| Language                   | Client                                                        |
| -------------------------- | ------------------------------------------------------------- |
| Rust                       | crates `yellowstone-grpc-client` and `yellowstone-grpc-proto` |
| Go                         | `examples/golang` in the upstream repository                  |
| TypeScript, Python, others | `examples/` in the upstream repository                        |

## What streams

Five update kinds are produced: **accounts, transactions, slots, entries and block metadata**, at `PROCESSED`, `CONFIRMED` or `FINALIZED`. Each update is delivered once. Slot status transitions and block metadata each arrive a single time per slot.

Every update carries `filters`, the list of filter names that matched it, and `created_at`, a server-side `google.protobuf.Timestamp`. One update lists every filter name it matched, so a single message can satisfy more than one filter.

<Warning>
  **Two filters are accepted and never produce data.** `blocks` and `transactions_status` are validated and held on the subscription, and neither ever fires. A subscription using only those filters receives nothing and reports no error. `transactions` covers the transaction status case. `blocks_meta` combined with `transactions` covers the block case.
</Warning>

### Methods

All eight RPCs are implemented. `Subscribe` is the bidirectional stream. The seven unary methods answer from the same data the stream is built on, so a client holding a stream reads a blockhash or a slot without opening a JSON-RPC connection.

| Method                | Kind                 |
| --------------------- | -------------------- |
| `Subscribe`           | bidirectional stream |
| `SubscribeReplayInfo` | unary                |
| `Ping`                | unary                |
| `GetLatestBlockhash`  | unary                |
| `GetBlockHeight`      | unary                |
| `GetSlot`             | unary                |
| `IsBlockhashValid`    | unary                |
| `GetVersion`          | unary                |

## Filters

Each filter map key is an independent entry. An update is delivered if **any** entry matches it fully, and within one entry every populated field must hold. Sub-filters belong to their own entry and never gate frames matched by a different entry.

The consequence in practice: a subscription carrying thirty owner filters plus one narrow `memcmp` filter receives everything the thirty owners produce, plus the `memcmp` matches. The `memcmp` does not narrow the other thirty. Constraints that must apply together belong in a single entry.

The map key is a label. It comes back in `filters` to tell streams apart and is never compared against chain data. The addresses do the matching.

### Transaction filters

`SubscribeRequestFilterTransactions`:

| Field              | Semantics when set                             |
| ------------------ | ---------------------------------------------- |
| `vote`             | delivered only if `is_vote` equals this        |
| `failed`           | delivered only if the failure flag equals this |
| `account_include`  | the transaction touches at least one of these  |
| `account_exclude`  | the transaction touches none of these          |
| `signature`        | exact signature match                          |
| `account_required` | the transaction touches every one of these     |

An unset field is not a constraint, and an empty list is the same as unset. Leaving every field unset is a wildcard that streams every transaction, which is permitted and is how a firehose subscription is expressed. `account_include` is an OR across addresses. `account_required` is the AND version, which suits pinning a program and pool pair. Vote transactions dominate the raw feed by volume, so `vote: false` and `failed: false` is the usual starting point.

### Account filters

`SubscribeRequestFilterAccounts`:

| Field                    | Semantics                                                               |
| ------------------------ | ----------------------------------------------------------------------- |
| `account`                | match these account addresses                                           |
| `owner`                  | match accounts owned by these programs                                  |
| `filters`                | repeated sub-filters, all of which must hold, maximum 4                 |
| `nonempty_txn_signature` | require, or require the absence of, an associated transaction signature |

Sub-filters, from `SubscribeRequestFilterAccountsFilter`:

| Sub-filter            | Shape                                              | Notes                            |
| --------------------- | -------------------------------------------------- | -------------------------------- |
| `memcmp`              | `offset` plus one of `bytes`, `base58` or `base64` | data capped at 128 bytes decoded |
| `datasize`            | account data length in bytes                       | may appear once per entry        |
| `lamports`            | one of `eq`, `ne`, `lt`, `gt`                      | `cmp` must be set                |
| `token_account_state` | SPL token account state                            | **not supported, rejected**      |

`memcmp` requires decoding account data and is the expensive one. `owner` or `datasize` as the first cut, with `memcmp` refining inside the same entry, costs less.

### Slot, entry and block metadata filters

`SubscribeRequestFilterSlots` takes `filter_by_commitment`, which delivers only slot updates whose status matches the requested commitment, and `interslot_updates`, which controls whether the intermediate transitions arrive. `SubscribeRequestFilterEntry` and `SubscribeRequestFilterBlocksMeta` are empty messages, so a named entry subscribes to the whole kind.

### Encoding

Pubkeys, owners, signatures and hashes are raw bytes on the wire. Filters are given as base58 strings. Forgetting to encode when logging and decode when filtering is the most common cause of a filter that matches nothing.

### Reducing payload size

`accounts_data_slice` is a repeated `{offset, length}` on the request rather than on a filter. When present, account updates are re-encoded so `data` carries only the requested ranges. For a subscription watching one field in a large account layout this is the difference between kilobytes and tens of bytes per update, and it is the cheapest bandwidth reduction available.

### Cardinality limits

Checked when a `SubscribeRequest` arrives, on live and historical subscriptions alike. Exceeding one ends the stream with `INVALID_ARGUMENT` and a message naming the limit.

| Limit                          | Value                                                                       |
| ------------------------------ | --------------------------------------------------------------------------- |
| Transaction filter entries     | 64, counting `transactions` and `transactions_status` together              |
| Account filter entries         | 64                                                                          |
| Pubkeys per transaction filter | 100,000, across `account_include`, `account_exclude` and `account_required` |
| Pubkeys per account filter     | 100,000, across `account` and `owner`                                       |
| Sub-filters per account entry  | 4                                                                           |
| Wildcard subscriptions         | Allowed, for both transactions and accounts                                 |

The 64 counts distinct named map keys rather than addresses, so one entry holding several thousand pubkeys is one entry. The 100,000 ceiling is theoretical, because a request that large exceeds the 4 MB message cap first.

### Rejection rather than silent widening

An address that fails base58 decoding, a signature of the wrong length, a `lamports` sub-filter with no comparator, a `datasize` given twice, an oversized `memcmp` payload and an unsupported `token_account_state` all end the subscription with `INVALID_ARGUMENT` and a message naming the offending field and value.

A filter entry that lost its only address constraint would be a wildcard over the whole kind, so the request fails rather than quietly turning a narrow subscription into a firehose.

### A worked request

Non-vote, non-failed transactions touching two programs, plus slot updates:

```rust theme={null}
SubscribeRequest {
    transactions: HashMap::from([
        (
            "pump-amm".to_string(),
            SubscribeRequestFilterTransactions {
                vote: Some(false),
                failed: Some(false),
                account_include: vec![
                    "pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA".to_string(),
                ],
                ..Default::default()
            },
        ),
        (
            "raydium-v4".to_string(),
            SubscribeRequestFilterTransactions {
                vote: Some(false),
                failed: Some(false),
                account_include: vec![
                    "675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8".to_string(),
                ],
                ..Default::default()
            },
        ),
    ]),
    slots: HashMap::from([("slots".to_string(), SubscribeRequestFilterSlots::default())]),
    commitment: Some(CommitmentLevel::Processed as i32),
    ..Default::default()
}
```

Updates arrive with `filters: ["pump-amm"]`, `filters: ["raydium-v4"]` or `filters: ["slots"]`. A transaction touching both programs lists both names.

## Commitment and slot status

Commitment is set once per request through the `commitment` field and applies to the whole subscription. Three levels are accepted: `PROCESSED`, `CONFIRMED` and `FINALIZED`.

Commitment promotion is buffered per slot rather than re-fetched, so a `CONFIRMED` or `FINALIZED` subscriber receives the same bytes the `PROCESSED` subscriber saw, promoted when the status transition arrives.

A slot's fate is reported by `SubscribeUpdateSlot.status`:

| Value                       | Meaning                           |
| --------------------------- | --------------------------------- |
| `SLOT_FIRST_SHRED_RECEIVED` | first shred seen                  |
| `SLOT_CREATED_BANK`         | bank created                      |
| `SLOT_PROCESSED`            | processed                         |
| `SLOT_COMPLETED`            | all shreds received               |
| `SLOT_CONFIRMED`            | confirmed                         |
| `SLOT_FINALIZED`            | finalized, terminal               |
| `SLOT_DEAD`                 | abandoned, reason in `dead_error` |

Slots are never retracted and a replaced slot is not re-sent. A fork-aware client follows these transitions, treats anything below `SLOT_FINALIZED` as provisional, and unwinds work built on a slot that reports `SLOT_DEAD`. `interslot_updates` on a slot filter delivers the intermediate transitions rather than only the commitment-level ones. Each status fires once per slot.

## Historical replay

Replay is triggered by setting `from_slot` on a `SubscribeRequest`. It is field 11 of the stock upstream message, so no special client, no SDK and no separate endpoint is involved. The stream replays forward from that slot and tails into live on the same connection, with no second reconnect and no gap.

|                            | Value                  |
| -------------------------- | ---------------------- |
| Retention window           | 48 hours               |
| Concurrent replay sessions | 64                     |
| Catch-up rate              | Capped at 10x realtime |

`SubscribeReplayInfo` returns `first_available`, the oldest slot currently held. The window drifts above the 48 hour floor because pruning runs on a schedule rather than instantly, so `first_available` is the value to branch on in code and 48 hours is the figure to plan against.

The 64 session limit applies per customer rather than across the platform. Beyond it, a new replay subscription receives `grpc-status: 8` and a retry message. Live subscriptions are never affected by replay load.

**Account updates are not retained.** A `from_slot` subscription carrying an `accounts` filter returns nothing for the historical portion and begins delivering at the live edge. Transactions, entries, blocks and block metadata are retained.

Every filter applies to replayed data exactly as it applies to live data. Addresses, owners, data size, lamports, the vote and failed flags, `memcmp` sub-filters and exact `signature` matching all behave identically on either path.

A `from_slot` older than the retained window is rejected with `grpc-status: 11` and a message naming the oldest slot still held. The available window is read out of that message rather than by retrying the same slot, because a reconnect loop that always sets the same `from_slot` after a rejection spins forever.

The 10x cap is a real constraint on catching up. A full day of history takes roughly two and a half hours to replay. A subscription that needs to be current quickly starts from a nearer `from_slot`.

## Limits and client behavior

### Message sizes

| Direction                             | Limit      |
| ------------------------------------- | ---------- |
| Client to server (`SubscribeRequest`) | 4 MB       |
| Server to client (`SubscribeUpdate`)  | Not capped |

A `SubscribeRequest` over 4 MB is rejected and the stream ends with a status rather than staying open.

Because outbound messages are not capped, the binding constraint is the client's own decode limit. Most gRPC stacks default to 4 MB. A subscription covering large accounts or busy blocks raises `max_decoding_message_size` on the Yellowstone client builder rather than relying on that default.

### Subscriptions and connections

One `SubscribeRequest` per stream, replaceable in place: sending another replaces the active filter without reconnecting. A request carrying only `ping` is not treated as a filter update. There is no per-credential connection cap, and `max_concurrent_streams` is unset, so subscriptions multiplex freely over one connection.

### Compression

Compression is not enabled and not negotiated. The server advertises no accepted or sent encodings, so every stream is uncompressed protobuf regardless of what a client offers in `grpc-accept-encoding`.

### Keepalives

A `SubscribeUpdate` carrying `UpdateOneof::Ping` arrives on every stream every 15 seconds, including idle ones, so a filtered subscription that matches nothing still survives NAT and load-balancer idle timeouts. It is a keepalive and does not count as data. Keepalives carry `created_at` like every other update, so staleness computed from that field needs no special case.

There is no server-side idle timeout. A stream lives until the client ends it, the connection breaks, or the client falls behind.

`Ping` and `Pong` also exist as a client-initiated pair: a `SubscribeRequest` carrying only `ping` with an id returns the same id as `SubscribeUpdatePong`.

### Falling behind

<Warning>
  **Drops are not signaled.** There is no dropped-message count, no gap marker and no error status. When a client cannot keep up, its queue fills, the server removes it, and the stream simply ends. A client that logs only gRPC errors sees nothing at all, so an unexpected clean end of stream means the client fell behind rather than a normal shutdown.
</Warning>

Ordinary HTTP/2 flow control applies first. A slow reader stalls its own stream and affects neither other streams on its connection nor other subscribers. The stream closes only once the per-client queue fills and does not recover.

Remedies, in the order they usually work: move processing off the thread that drains the stream, narrow the filter, apply `accounts_data_slice` to cut payload size, read faster.

### Resuming

There is no server-side cursor. Consumption is not tracked, so a reconnect starts live unless the request asks for something else. Resumption is the client's decision, through `from_slot`: track the last slot processed, set `from_slot` to it on reconnect, and the stream replays forward and tails into live on the same connection.

A client that does not need gap-free delivery reconnects with no `from_slot`. That path is cheaper and cannot fail on a retention boundary. `from_slot` resumption suits state reconciliation that genuinely cannot tolerate a hole.

## Errors and retries

Rejections carry an HTTP status, a gRPC status code, an `x-error-code` header and a `grpc-message`. `x-error-code` is stable and unique per condition and is the field to branch on. The prose is not stable and is better displayed than matched. `grpc-message` is always `<message> (code: <X_ERROR_CODE>)`.

| Condition                                                             | HTTP | `grpc-status` | `x-error-code`          | Retry                                               |
| --------------------------------------------------------------------- | ---- | ------------- | ----------------------- | --------------------------------------------------- |
| Token missing, invalid, expired, or from a non-allow-listed address   | 401  | 16            | `UNAUTHENTICATED`       | No                                                  |
| Credentials could not be verified, authentication briefly unavailable | 500  | 13            | `AUTH_UNAVAILABLE`      | Yes, backoff                                        |
| Too many distinct invalid credentials from this address               | 429  | 8             | `AUTH_RATE_LIMITED`     | Yes, once the block expires, with valid credentials |
| Credential checking saturated, load being shed                        | 503  | 14            | `AUTH_BUSY`             | Yes, backoff                                        |
| Not a gRPC request, wrong `content-type`                              | 415  | 3             | `INVALID_CONTENT_TYPE`  | No                                                  |
| No node currently available                                           | 503  | 14            | `NO_CAPACITY`           | Yes, backoff                                        |
| All nodes briefly out of service                                      | 503  | 14            | `UPSTREAMS_UNAVAILABLE` | Yes, backoff                                        |
| Node did not complete the request                                     | 502  | 14            | `UPSTREAM_ERROR`        | Yes, backoff                                        |

`UNAUTHENTICATED` covers a missing token, an invalid one, an expired one and a disallowed source address under one code, so a report of it is checked against the IP allow-list as well as against the credential.

`AUTH_RATE_LIMITED` counts distinct invalid credentials from one address rather than failed attempts. A client stuck in a reconnect loop with one stale or revoked token retries it indefinitely without being blocked, and keeps receiving `UNAUTHENTICATED`. It is a configuration error to surface rather than a transient to retry.

Conditions raised on the streaming path carry a status and a message but no `x-error-code`:

| Condition                                    | `grpc-status`             | Retry                                               |
| -------------------------------------------- | ------------------------- | --------------------------------------------------- |
| Too many transaction filters, maximum 64     | 3                         | No. Narrow the filter                               |
| Too many account filters, maximum 64         | 3                         | No                                                  |
| Too many pubkeys, maximum 100,000            | 3                         | No                                                  |
| Malformed address, signature or sub-filter   | 3                         | No. The message names the offending field and value |
| `SubscribeRequest` above 4 MB                | 3                         | No                                                  |
| `from_slot` older than the retained window   | 11                        | No. Resubscribe from the slot named in the message  |
| Replay session capacity reached              | 8                         | Yes, backoff                                        |
| No `SubscribeRequest` within 30 s of opening | 1                         | Yes                                                 |
| Client fell behind and its queue filled      | none, clean end of stream | Reconnect                                           |
| Connection dropped before the TLS handshake  | none, connection failure  | Yes, backoff                                        |

Both `OUT_OF_RANGE` wordings name the oldest retained slot, so parsing for `retained from` is more reliable than matching either string whole.

### Backoff

Statuses 13 and 14 retry with exponential backoff starting at 100 ms, capped at 30 seconds, with jitter. That covers `AUTH_UNAVAILABLE`, `AUTH_BUSY`, `NO_CAPACITY`, `UPSTREAMS_UNAVAILABLE` and `UPSTREAM_ERROR`. Status 8 carrying the replay capacity message retries the same way, as does a bare connection failure.

Retrying 16 or 3 without changing the credential or the request repeats the same failure. On 11, the available window is read out of the message and the subscription resumes from there.
