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 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.
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.
| 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.
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.
| 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,
accountandownermust both match when both are set. - Across named filters, results are OR. An update is sent if it matches any of your filters.
update.filterslists 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
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.
| 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 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.
| 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
fromSlotreplays 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.
| 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
depositConfidentialTransferandwithdrawConfidentialTransferno longer carrysourceanddestination. They carry a singleaccountinstead.- More Token-2022 instructions parse than before, including permissioned burn,
unwrapLamports,confidentialBurnand the batch operations. - A mint with an extension the parser does not know now parses anyway. It used to return an empty
extensionsarray. 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.