Filters

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

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

Prop

Type

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 mapDelivers
accountsAccount updates, by address, by owner program or by data filter
transactionsFull transactions with execution metadata
transactionsStatusThe same matching, status only, without the transaction
slotsSlot status changes
blocksWhole blocks
blocksMetaBlock headers, without transactions
entryProof-of-history entries

Subscriptions are refused for three reasons only: your plan, match-all filters and filter size. See 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

Prop

Type

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:

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.

Data filters

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

FilterMatches
memcmpBytes at an offset, given as bytes, base58 or base64
datasizeAn exact account data length, in bytes
lamportsA balance, compared with eq, ne, lt or gt
tokenAccountStateInitialised 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.

Prop

Type

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:

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

Slot filters

Prop

Type

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.

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

Commitment levels

One commitment level applies to the whole stream.

LevelBehaviour
PROCESSEDFastest. Some processed slots are later skipped, so work done on them can need undoing.
CONFIRMEDWaits until a supermajority of stake has voted on the block. The safe default for trading logic.
FINALIZEDFor 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

LimitBlade RunnerNexus
Account filters per stream1564
Accounts per filter1002,000
Owners per filter3850
Accounts per stream (auto-split)1,500128,000
Accounts across all streams (streams × accounts per stream)about 112,500about 25,600,000
Transaction filters per stream1532
Accounts per transaction filter1001,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

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 and transaction filters in #849. Both are in the 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.

FilterFieldStands in for
accountscuckooAccountsFilteraccount
transactions, transactionsStatuscuckooAccountIncludeaccountInclude
blockscuckooAccountIncludeaccountInclude

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

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.

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.

FieldMeaning
dataThe bucket data
bucketCountNumber of buckets
entriesPerBucketSlots per bucket, typically 4
fingerprintBitsFingerprint size: 8, 12 or 16
hashSeedSeed for deterministic hashing
hashAlgorithmSIP_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.
  • A rejected request ends the stream. Your previous filter is not kept, so the corrected request goes on a new connection.
// 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.

ConditiongRPC statusMessage
Key missing, unknown or revokedUNAUTHENTICATED (16)Missing or Invalid API Key
Plan below Blade RunnerPERMISSION_DENIED (7)Permission Denied: Insufficient Tier
A match-all filter on Blade RunnerPERMISSION_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 allowsRESOURCE_EXHAUSTED (8)too many concurrent match-all (any) streams for this key (max 3)
More streams than your plan allowsRESOURCE_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 splitINVALID_ARGUMENT (3)Names the filter and what is wrong with it
Resubscribing with a filter already rejectedRESOURCE_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 windowOUT_OF_RANGE (11)broadcast from <slot> is not available, last available: <oldest>
Unknown methodUNIMPLEMENTED (12)Method Not Implemented
Server-side failureINTERNAL (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.

ChangeWhat it breaks
A write-locked account that ends the transaction unchanged no longer emits an updateCorrelating a transaction to its accounts, and any liveness check that counts updates
Deactivated stake has its own rewardTypeA closed enum, or a filter that matches only "staking"
Token-2022 confidential transfers parse differentlyReaders of source and destination on those instructions
Version 1 transactions carry their fee inside the messageFee 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.

On this page