# Private Solana feeds and feed cadence

> Point ingest at your own Yellowstone-Geyser endpoint, understand how the x-token is sealed, and tune tick_ms, blockhash_every and fee_every.

The **Data feeds** screen has two halves. The top half monitors the three
MoQT-published tracks that FirstByte runs for you (`slots`,
`blockhash`, `priorityFee`). The bottom half — **Private Geyser feed** and
**Feed cadence** — is per-organization configuration: you supply your own
Yellowstone-Geyser gRPC endpoint, and you choose how often the engine
publishes each field.

This page documents that per-organization half.

## What a private feed is

Normally `mod_sol_feeds` ingests Solana state and publishes it to shared
tracks. With a private feed you give the module **your own**
Yellowstone-Geyser gRPC endpoint instead. The engine (shard 0) ingests
slots from that endpoint over HTTP/2 and republishes them to a MoQT track
that belongs only to your org.

Two things follow from that:

- The data path is yours. Freshness and update rate depend on the Geyser
  provider you point at (Triton, Helius, or a self-hosted validator), not
  on the shared feed.
- The track is namespaced under your org, so a relay token scoped to
  another org cannot subscribe to it.

## Feed namespace and track names

A configured private feed occupies the namespace:

```
sol/feed/<orgId>
```

and the slot track inside it:

```
sol/feed/<orgId>/slots
```

`geyser.get` returns the namespace as `namespace`, so the console renders
the real value rather than deriving it. Before you sign in or before an
org is selected, the screen falls back to the literal placeholder
`sol/feed/<org>` — that is a display placeholder, not a usable namespace.

The header pill reflects `configured` from `geyser.get`: `live · <namespace>`
when an endpoint is stored, `not configured` otherwise.

## Configuring a Yellowstone-Geyser endpoint

The panel is scoped to the active organization and is gated on sign-in.
If you are not signed in, you get a **Sign in to configure** state with a
link to the portal; the portal session is shared with the console.

Two fields:

| Field | Required | Notes |
| ----- | -------- | ----- |
| `grpc_url` | Yes | Must be an `https://` URL. Leading and trailing whitespace is trimmed before submit. |
| `x-token` | No | Auth token for the endpoint. Leave blank to keep the token already stored. |

Validation runs client-side before the mutation fires. An empty or
whitespace-only `grpc_url`, or a URL whose protocol is not `https`, blocks
the save and marks the field invalid — the request is not sent. Errors are
only surfaced after the first save attempt.

Saving calls:

```ts
geyser.set({ orgId, grpcUrl, xToken /* optional */ })
```

On success the screen refetches `geyser.get` and `geyser.verify`, and
clears the token input so the secret does not linger in the DOM.

If the mutation itself fails, the server's message is rendered under the
fields verbatim.

## Credential sealing and the unseal failure path

The `x-token` is **sealed at rest under your org**. The console never reads
it back. `geyser.get` exposes only `hasToken` — whether a token is stored —
and the input renders `•••••••• (unchanged)` as its placeholder in that
case.

Separately, `geyser.verify` answers a different question: can the
engine-side KEK actually open the sealed credential? The **Token** stat
combines both signals:

| `hasToken` | `verify.ok` | Token stat | Meaning |
| ---------- | ----------- | ---------- | ------- |
| `false` | — | `—` | No token stored. Fine if your endpoint needs no auth. |
| `true` | not `false` | `sealed ✓` | A token is stored and the engine can unseal it. |
| `true` | `false` | `unreadable` (warning tone) | A token is stored but the engine's KEK cannot open it. |

When the state is `unreadable`, the panel shows:

> The stored token can't be unsealed — re-enter it to reseal.

Operationally, treat this as **the endpoint is effectively unauthenticated
right now**. The ciphertext is present but the engine cannot recover the
plaintext, so ingest cannot present a valid `x-token` to your Geyser
provider. There is no repair action other than resealing: paste the token
into the `x-token` field again and save. That runs `geyser.set` with a
fresh seal, and the subsequent `verify` refetch should flip the stat back
to `sealed ✓`.

Because the old value is unrecoverable by design, you need the token from
your Geyser provider, not from FirstByte.

## Feed cadence: tick_ms, blockhash_every, fee_every

Cadence is stored in the same per-tenant geyser config that holds your
endpoint, and it is read back by `geyser.get` as `tickMs`,
`blockhashEvery` and `feeEvery`.

| Field | Console range | Step | Value used when unset | What it controls |
| ----- | ------------- | ---- | --------------------- | ---------------- |
| `tick_ms` | 100 – 1000 | 50 | 400 | Base poll interval, in milliseconds. |
| `blockhash_every` | 1 – 20 | 1 | 5 | Publish `blockhash` every N ticks. |
| `feeEvery` / `fee_every` | 1 – 20 | 1 | 8 | Publish `priorityFee` every N ticks. |

The two `*_every` values are **multiples of the tick, not independent
intervals**. The effective publish interval for a field is:

```
interval_ms = tick_ms × <field>_every
```

So the console defaults — `tick_ms = 400`, `blockhash_every = 5`,
`fee_every = 8` — mean the base poll runs every 400 ms, `blockhash`
publishes every 2000 ms, and `priorityFee` publishes every 3200 ms.
Lowering `tick_ms` speeds up *everything* downstream of it; raising a
`*_every` thins out one field without touching the others.

Slider hydration has one quirk worth knowing: when the config loads, the
console accepts the stored `tickMs` only if it is at least 100, otherwise
it displays 400. `blockhashEvery` and `feeEvery` fall back to 5 and 8
respectively when absent or zero. If you saved a cadence out-of-band and
the slider shows a different number than you expect, that fallback is why.

Saving calls:

```ts
geyser.setCadence({ orgId, tickMs, blockhashEvery, feeEvery })
```

Like the endpoint panel, the whole cadence panel is gated on sign-in —
anonymous visitors see **Sign in to configure**, not live sliders.

## When a cadence change takes effect

A saved cadence is written to your feed config immediately and
`geyser.get` is refetched, so the sliders reflect the stored state as soon
as the mutation resolves.

Propagation to the publisher is separate from persistence:

- `mod_sol_feeds` picks up a live cadence change within roughly five
  seconds. You do not restart anything and you do not resubscribe.
- The **per-tenant blockhash and priorityFee poll is still rolling out**.
  Until it is enabled engine-side, a private feed is **slots-only**: your
  `blockhash_every` and `fee_every` values are persisted and validated, but
  no per-tenant blockhash or fee publishing happens yet. The success
  caption on the screen says exactly this.

So a cadence save today is best understood as a durable declaration of
intent for `blockhash` / `priorityFee`, and a live change for the tick that
drives slot publishing.

## Subscribing to your private track

Private tracks are published over MoQT and subscribed over QUIC, the same
as the shared tracks listed at the top of the screen. The name to subscribe
to is the one shown in the **Track** stat, `sol/feed/<orgId>/slots`.

Access is enforced by namespace scope on the relay token, not by the feed
config. The token you present on the MoQT handshake must carry a namespace
claim that covers `sol/feed/<orgId>` — see
[Authentication](/concepts/authentication) for how `ns` claims are minted
and checked. A token scoped to a different org's namespace is rejected at
subscribe time.

## Why freshness, rate and subscribers show a dash

On the track cards and in the selected-track detail, **Freshness**,
**Rate** and **Subscribers** render `—` when the underlying value is
unmeasured. The console deliberately does not substitute `0`, because
"no measurement" and "measured zero" are different conditions and
conflating them hides a broken telemetry path.

A dash means no sample has arrived for that counter. It does not by itself
mean the track is down — the track's own `status` pill and the
`push · WS` / `poll · HTTP` mode pill are the signals for that.

## Removing a private feed

The **Remove** button appears only when `configured` is true. It calls:

```ts
geyser.clear({ orgId })
```

and then refetches both `geyser.get` and `geyser.verify`, which flips the
header pill back to `not configured` and the **Token** stat back to `—`.

Clearing removes the endpoint and the sealed token for the org. Ingest from
your Geyser endpoint stops, and with it publishing to
`sol/feed/<orgId>/slots`. Any subscriber still attached to that track stops
receiving objects. Re-adding a feed later requires the `x-token` again,
since the sealed copy is gone.

## Procedures used by this screen

| Procedure | Input | Purpose |
| --------- | ----- | ------- |
| `feeds.list` | — | The three monitored tracks, their last payload, and the slot / fee sparklines. |
| `geyser.get` | `orgId` | `grpcUrl`, `configured`, `hasToken`, `namespace`, `tickMs`, `blockhashEvery`, `feeEvery`. |
| `geyser.verify` | `orgId` | `{ ok }` — whether the engine's KEK can unseal the stored token. |
| `geyser.set` | `orgId`, `grpcUrl`, `xToken?` | Store the endpoint; reseal the token when one is supplied. |
| `geyser.setCadence` | `orgId`, `tickMs`, `blockhashEvery`, `feeEvery` | Store the publish cadence. |
| `geyser.clear` | `orgId` | Delete the endpoint and the sealed token. |

`geyser.get`, `geyser.verify`, `geyser.set`, `geyser.setCadence` and
`geyser.clear` are all keyed on the active org, and every mutation is
disabled until an org is selected.

## Related

- [Authentication](/concepts/authentication) — relay tokens and `ns` namespace scope
- [Telemetry](/platform/telemetry) — where freshness and rate metrics come from
