# Filters

> Filter types, fields and limits that decide what a Kaldera stream delivers.

Source: https://docs.constant-k.com/kaldera/filters/

Every Kaldera stream starts with a `SubscribeRequest` holding named filters and stream-wide options. Kaldera applies them on the server and sends only what matches.

Field names below are the TypeScript names from `@triton-one/yellowstone-grpc`. The Rust and protobuf names use `snake_case`: `accountInclude` becomes `account_include`.

## Request fields

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `accounts` (required) | `Record<string, SubscribeRequestFilterAccounts>` |  | Named account filters. |
| `transactions` (required) | `Record<string, SubscribeRequestFilterTransactions>` |  | Named transaction filters. |
| `transactionsStatus` (required) | `Record<string, SubscribeRequestFilterTransactions>` |  | Named transaction filters that return status only. |
| `slots` (required) | `Record<string, SubscribeRequestFilterSlots>` |  | Named slot filters. |
| `blocks` (required) | `Record<string, SubscribeRequestFilterBlocks>` |  | Named block filters. An empty object when unused. |
| `blocksMeta` (required) | `Record<string, SubscribeRequestFilterBlocksMeta>` |  | Named block-meta filters. An empty object when unused. |
| `entry` (required) | `Record<string, SubscribeRequestFilterEntry>` |  | Named entry filters. An empty object when unused. |
| `accountsDataSlice` (required) | `{ offset: string; length: string }[]` |  | Byte ranges to keep from each account’s data. Empty means all data. |
| `commitment` | `CommitmentLevel` | `PROCESSED` | PROCESSED, CONFIRMED or FINALIZED. Applies to the whole stream. |
| `fromSlot` | `string` |  | Replay from this slot. See the Replay page. |
| `ping` | `SubscribeRequestPing` |  | Keepalive. A request that carries only a ping does not change your filters. |

Each filter carries a name you choose. Every update carries a `filters` array naming the filters it matched.

## Filter types

Kaldera accepts every Yellowstone filter type.

| Filter map           | Delivers                                                        |
| :------------------- | :-------------------------------------------------------------- |
| `accounts`           | Account updates, by address, by owner program or by data filter |
| `transactions`       | Full transactions with execution metadata                       |
| `transactionsStatus` | The same matching, status only, without the transaction         |
| `slots`              | Slot status changes                                             |
| `blocks`             | Whole blocks                                                    |
| `blocksMeta`         | Block headers, without transactions                             |
| `entry`              | Proof-of-history entries                                        |

Subscriptions are refused for three reasons only: your plan, match-all filters and filter size. See [Rejected subscriptions](#rejected-subscriptions).

> **Block filters that ask for accounts or entries:** A `blocks` filter with `includeAccounts: true` or `includeEntries: true` is refused by the node that feeds the stream, and the refusal reaches you as a stream error. `includeTransactions` works. That data comes from separate `accounts` or `entry` filters.

## Account filters

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `account` (required) | `string[]` |  | Account addresses to watch. Matches any address in the list. |
| `owner` (required) | `string[]` |  | Owner programs. Matches any account owned by a program in the list. |
| `filters` (required) | `SubscribeRequestFilterAccountsFilter[]` |  | Data filters (memcmp, datasize). An empty array when unused. |

Each account update carries the `pubkey`, `owner`, `lamports`, `data`, `executable`, `rentEpoch` and `writeVersion` of the account, and the `slot`. Keys arrive as raw bytes; base58 encoding turns them into address strings.

This request watches SPL Token accounts owned by one wallet and keeps the first 64 bytes of data:

```ts
import { CommitmentLevel, type SubscribeRequest } from "@triton-one/yellowstone-grpc";

const request: SubscribeRequest = {
  commitment: CommitmentLevel.PROCESSED,
  accounts: {
    myTokenAccounts: {
      account: [],
      owner: ["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"], // SPL Token
      filters: [
        { datasize: "165" },
        { memcmp: { offset: "32", base58: "YOUR_WALLET_ADDRESS" } },
      ],
    },
  },
  accountsDataSlice: [{ offset: "0", length: "64" }],
  slots: {},
  transactions: {},
  transactionsStatus: {},
  blocks: {},
  blocksMeta: {},
  entry: {},
  blockFooter: {},
};
```

> **An account that is written to but not changed stays quiet:** A transaction that takes a write lock on an account and leaves it unchanged sends you nothing for that account. The fee payer is the exception. See [Changes in the data you receive](#changes-in-the-data-you-receive).

### Data filters

Data filters sit inside an account filter, like the filters of `getProgramAccounts`. All four are honoured.

| Filter              | Matches                                                    |
| :------------------ | :--------------------------------------------------------- |
| `memcmp`            | Bytes at an offset, given as `bytes`, `base58` or `base64` |
| `datasize`          | An exact account data length, in bytes                     |
| `lamports`          | A balance, compared with `eq`, `ne`, `lt` or `gt`          |
| `tokenAccountState` | Initialised token accounts only                            |

Kaldera checks `memcmp` offsets at subscribe time. An offset past the largest account size Solana allows can never match, so the subscription is rejected with a message naming the filter, the offset and the maximum size.

## Transaction filters

Updates include the full transaction and its execution metadata (`meta`): fee, error, logs, balance changes, inner instructions and compute units.

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `vote` | `boolean` |  | false drops vote transactions, true keeps only them. Leave unset for both. |
| `failed` | `boolean` |  | false drops failed transactions, true keeps only them. Leave unset for both. |
| `signature` | `string` |  | Match one transaction by signature. |
| `accountInclude` (required) | `string[]` |  | Match transactions that use any of these accounts. |
| `accountExclude` (required) | `string[]` |  | Drop transactions that use any of these accounts. |
| `accountRequired` (required) | `string[]` |  | Match only transactions that use all of these accounts. |

Almost every stream sets `vote: false`: vote transactions are the majority of Solana traffic.

This filter map matches successful Jupiter transactions that also touch the USDC mint:

```ts
transactions: {
  jupiterUsdc: {
    vote: false,
    failed: false,
    accountInclude: [],
    accountRequired: [
      "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4", // Jupiter v6
      "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC mint
    ],
    accountExclude: [],
  },
},
```

## Slot filters

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `filterByCommitment` | `boolean` | `false` | true sends slot updates only at the stream’s commitment level. |
| `interslotUpdates` | `boolean` | `false` | true adds intermediate statuses, such as first shred received and completed. |

Each slot update carries the `slot`, its `parent` and a `status`: `SLOT_PROCESSED`, `SLOT_CONFIRMED` or `SLOT_FINALIZED`, plus the intermediate statuses when `interslotUpdates` is on.

```ts
slots: {
  tip: { filterByCommitment: false, interslotUpdates: false },
},
```

## Commitment levels

One commitment level applies to the whole stream.

| Level       | Behaviour                                                                                        |
| :---------- | :----------------------------------------------------------------------------------------------- |
| `PROCESSED` | Fastest. Some processed slots are later skipped, so work done on them can need undoing.          |
| `CONFIRMED` | Waits until a supermajority of stake has voted on the block. The safe default for trading logic. |
| `FINALIZED` | For records that must never change.                                                              |

## How filters combine

* **Inside a list, values are OR.** `accountInclude: [A, B]` matches transactions that touch A or B.
* **Across fields, conditions are AND.** In an account filter, `account` and `owner` must both match when both are set.
* **Across named filters, results are OR.** An update is sent if it matches any of your filters. `update.filters` lists which ones.

## Filter limits

| Limit                                                       |  Blade Runner |       Nexus      |
| :---------------------------------------------------------- | :-----------: | :--------------: |
| Account filters per stream                                  |       15      |        64        |
| Accounts per filter                                         |      100      |       2,000      |
| Owners per filter                                           |       38      |        50        |
| Accounts per stream (auto-split)                            |     1,500     |      128,000     |
| Accounts across all streams (streams × accounts per stream) | about 112,500 | about 25,600,000 |
| Transaction filters per stream                              |       15      |        32        |
| Accounts per transaction filter                             |      100      |       1,000      |
| Match-all ("any") streams                                   |       —       |      Up to 3     |

An `accounts`, `transactions` or `transactionsStatus` filter with nothing to narrow it matches everything of its type, for example every transaction on the network. That is a match-all stream, allowed on Nexus only, up to 3 per region.

A `memcmp` or `datasize` filter counts as narrowing, so an account filter that carries one is not a match-all stream. Blade Runner keys can use both.

A compressed account filter also counts as narrowing. The accounts inside it are not counted against the per-filter and per-stream account limits above, because they never appear in the request as a list. See [Compressed account filters](#compressed-account-filters).

## Compressed account filters

An account set can travel as a cuckoo filter instead of a list of pubkeys. The request then carries a compact binary structure whose size follows the capacity chosen rather than the number of accounts tracked, which is what makes a set larger than the list limits practical.

This is upstream Yellowstone, not a constant-k addition. Account and block filters gained it in [rpcpool/yellowstone-grpc#732](https://github.com/rpcpool/yellowstone-grpc/pull/732) and transaction filters in [#849](https://github.com/rpcpool/yellowstone-grpc/pull/849). Both are in the [protocol files](https://docs.constant-k.com/kaldera/#server-version-and-protocol-files) Kaldera runs.

A pubkey costs roughly 3 to 4 bytes in a compressed filter against 32 bytes as a raw key, and rather more as base58 text in a list, so a set that is impractical to send as a list fits in a few megabytes.

| Filter                               | Field                  | Stands in for    |
| :----------------------------------- | :--------------------- | :--------------- |
| `accounts`                           | `cuckooAccountsFilter` | `account`        |
| `transactions`, `transactionsStatus` | `cuckooAccountInclude` | `accountInclude` |
| `blocks`                             | `cuckooAccountInclude` | `accountInclude` |

The plaintext lists stay empty when a compressed filter is present: the set inside the filter is the narrowing constraint. A compressed filter combines with `owner`, `memcmp`, `datasize` and `nonemptyTxnSignature` exactly as the plaintext lists do.

[Turbo Kaldera](https://docs.constant-k.com/kaldera/turbo-kaldera/) has no equivalent field, because the `SubscribeDeshred` filters do not define one.

Only the include side has a compressed form. `accountExclude`, `accountRequired` and `owner` stay as lists, and combine with a compressed filter in the usual way.

### Building one

Both client packages carry `CompressedAccountFilterSet`, which owns the exact set, produces the wire message and inserts it into the request.

> **CompressedAccountFilterSet, not the raw filter:** The package also exposes the underlying `CuckooFilter`. Its `remove` can drop the wrong entry, which upstream documents as a footgun, and a filter built by any other cuckoo library will not match: the server derives buckets and fingerprints its own way. The wrapper is the supported path in both languages.

**TypeScript**

```ts
import { CompressedAccountFilterSet } from "@triton-one/yellowstone-grpc";

const tracked = new CompressedAccountFilterSet(2_000_000);
for (const pubkey of myAccounts) tracked.insert(pubkey);

const request = { transactions: {}, accounts: {}, slots: {}, blocks: {}, blocksMeta: {}, entry: {}, transactionsStatus: {}, blockFooter: {}, accountsDataSlice: [], commitment: 0 };
tracked.insertIntoTransactionSubscribeRequest(request, "tracked");

await client.subscribe(request);

// On each update, drop the false positives.
if (!tracked.contains(update.pubkey)) return;
```

`insertIntoSubscribeRequest` fills an `accounts` filter, and `insertIntoBlockSubscribeRequest` and `insertIntoTransactionStatusSubscribeRequest` cover the other two surfaces. `toAccountFilter`, `toTransactionFilter` and `toBlockFilter` return the filter on its own.

**Rust**

```rust
use yellowstone_grpc_proto::cuckoo::CompressedAccountFilterSet;

let mut tracked = CompressedAccountFilterSet::with_capacity(2_000_000)?;
for pubkey in my_accounts {
    tracked.insert(pubkey)?;
}

tracked.insert_into_subscribe_request(&mut request, "tracked");

// On each update, drop the false positives.
if !tracked.contains(pubkey) {
    return;
}
```

### False positives

A cuckoo filter answers "possibly in the set" or "certainly not in the set". A stream carrying one therefore delivers some updates for accounts outside the set. Nothing in the set is missed: a member never tests absent.

Re-checking each update with `contains` against the same set is what removes them, and it is not optional tidying. Without it a stream acts on accounts it does not track.

Upstream bounds the rate at under 1% at full capacity. At the default settings the wrapper chooses, 16-bit fingerprints over buckets of 4, the expected rate is nearer 0.01%.

### The wire message

`CompressedAccountFilterSet` fills these in. They matter when reading a request off the wire rather than building one.

| Field              | Meaning                            |
| :----------------- | :--------------------------------- |
| `data`             | The bucket data                    |
| `bucketCount`      | Number of buckets                  |
| `entriesPerBucket` | Slots per bucket, typically 4      |
| `fingerprintBits`  | Fingerprint size: 8, 12 or 16      |
| `hashSeed`         | Seed for deterministic hashing     |
| `hashAlgorithm`    | `SIP_HASH`, the only value defined |

## Changing filters on an open stream

`Subscribe` is bidirectional. Writing another `SubscribeRequest` to a stream you already hold re-filters that stream without a reconnect.

* **The new request replaces the old one,** and is checked again against your plan, the match-all rule and the size limits.
* **A small seam.** A few updates around the switch can be missed. Where that matters, a second stream opened and allowed to settle before the first closes covers the gap.
* **A ping-only request is a keepalive.** It is answered with a pong, and your filters stay as they are.
* **A request that carries `fromSlot` replays from that slot.** See [Replay](https://docs.constant-k.com/kaldera/replay/).
* **A rejected request ends the stream.** Your previous filter is not kept, so the corrected request goes on a new connection.

```ts
// Same stream, new filter: watch a second program from now on
stream.write({
  ...request,
  transactions: {
    jupiter: {
      vote: false,
      accountInclude: ["JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4"],
      accountExclude: [],
      accountRequired: [],
    },
  },
});
```

## Rejected subscriptions

A refused subscription ends the stream with a gRPC status. Codes are stable and wording can change between releases, so the code is the reliable thing to branch on.

| Condition                                         | gRPC status              | Message                                                                                                                                                                       |
| :------------------------------------------------ | :----------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Key missing, unknown or revoked                   | `UNAUTHENTICATED` (16)   | `Missing or Invalid API Key`                                                                                                                                                  |
| Plan below Blade Runner                           | `PERMISSION_DENIED` (7)  | `Permission Denied: Insufficient Tier`                                                                                                                                        |
| A match-all filter on Blade Runner                | `PERMISSION_DENIED` (7)  | `match-all (any) filters are available on the nexus tier only; narrow the filter or upgrade — retrying this request cannot succeed`                                           |
| More match-all streams than your plan allows      | `RESOURCE_EXHAUSTED` (8) | `too many concurrent match-all (any) streams for this key (max 3)`                                                                                                            |
| More streams than your plan allows                | `RESOURCE_EXHAUSTED` (8) | `Connection Limit Exceeded`, or a longer message naming the stream count and your plan                                                                                        |
| A filter too large to serve, that cannot be split | `INVALID_ARGUMENT` (3)   | Names the filter and what is wrong with it                                                                                                                                    |
| Resubscribing with a filter already rejected      | `RESOURCE_EXHAUSTED` (8) | `a recent subscription from this API key was rejected as unserviceable; retrying the identical filter cannot succeed — fix the filter (see the previous error) and reconnect` |
| `fromSlot` outside the replay window              | `OUT_OF_RANGE` (11)      | `broadcast from <slot> is not available, last available: <oldest>`                                                                                                            |
| Unknown method                                    | `UNIMPLEMENTED` (12)     | `Method Not Implemented`                                                                                                                                                      |
| Server-side failure                               | `INTERNAL` (13)          | `Internal Server Error`                                                                                                                                                       |

Two cannot be fixed by retrying: a match-all filter your plan does not allow, and a filter already rejected as unserviceable. The filter has to change before the reconnect, or the client loops.

## Changes in the data you receive

Four upstream changes alter what the validator hands Kaldera.

| Change                                                                               | What it breaks                                                                        |
| :----------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------ |
| A write-locked account that ends the transaction unchanged no longer emits an update | Correlating a transaction to its accounts, and any liveness check that counts updates |
| Deactivated stake has its own `rewardType`                                           | A closed enum, or a filter that matches only `"staking"`                              |
| Token-2022 confidential transfers parse differently                                  | Readers of `source` and `destination` on those instructions                           |
| Version 1 transactions carry their fee inside the message                            | Fee readers that scan for ComputeBudget instructions                                  |

### Write-locked but unchanged accounts are silent

An account emits an update only when the transaction changes it. A write lock is no longer enough on its own. The fee payer always emits.

Account updates can no longer rebuild the account list of a transaction; that list comes from a `transactions` filter. "No update" does not mean "nothing happened": a busy account can go quiet while transactions keep running against it.

### A new reward type for deactivated stake

Deactivation payouts arrive with their own `rewardType`, separate from `"staking"`. A filter that matches only `"staking"` misses the last payout of a stake account's life, and a client with a closed enum drops the update or fails on it. An open enum that logs unknown reward types avoids both.

### Token-2022 parsing changed

* `depositConfidentialTransfer` and `withdrawConfidentialTransfer` no longer carry `source` and `destination`. They carry a single `account` instead.
* More Token-2022 instructions parse than before, including permissioned burn, `unwrapLamports`, `confidentialBurn` and the batch operations.
* A mint with an extension the parser does not know now parses anyway. It used to return an empty `extensions` array. Code that read an empty array as "no extensions" now sees real data.

### Version 1 transactions

A version 1 transaction has no address lookup tables and no ComputeBudget instructions. Its priority fee, compute unit limit, loaded-accounts-data-size limit and heap size sit in a transaction config inside the message. That priority fee is a total in lamports, not a price per compute unit. A parser that scans instructions for the ComputeBudget program reports zero for every version 1 transaction. See [Transaction v1](https://docs.constant-k.com/rpc/transaction-v1/).
