Overview
Compressed filters (also known as cuckoo filters) let you track millions of accounts in a single Solana gRPC subscription without hitting pubkey limits. Instead of sending an explicit list of pubkeys, you send a compact binary filter that the server uses to match incoming updates.
A standard Solana gRPC subscription carries an explicit list of the pubkeys you want to match, and that list grows large fast: one million pubkeys is roughly 44 MB per request. Tracking hundreds or thousands of accounts (e.g., 500 wallets plus 700 token accounts) quickly exceeds those limits.
A compressed filter replaces that explicit list with a probabilistic data structure that encodes the same set in a fraction of the space:
- Bypasses pubkey limits: bounded only by message size (~99 MiB, which supports tens of millions of pubkeys).
- Reduces payload size by ~10x compared to an explicit pubkey list.
- Supports O(1) insert and remove operations, so you can update a large set in place instead of rebuilding it from scratch.
What Is a Compressed Filter?
A compressed filter is a probabilistic data structure that tests whether a pubkey belongs to a set without storing the full set. Instead of keeping each 32-byte pubkey, it stores a short fingerprint (a small hash) of each one in a table of buckets, cutting the space needed to represent the set by roughly 10x.
| Account Count | Compressed Filter | Explicit Pubkey List |
|---|---|---|
| 1,000 | ~4 KiB | ~44 KB |
| 10,000 | ~32 KiB | ~440 KB |
| 100,000 | ~256 KiB | ~4.4 MB |
| 1,000,000 | ~4 MiB | ~44 MB |
| 2,000,000 | ~8 MiB | ~84 MB |
How It Works
Building and using a compressed filter follows the same lifecycle regardless of language:
- Client builds the filter: Insert every pubkey you want to track into a
CompressedAccountFilterSet. The set keeps an exact copy of your pubkeys locally and a compact cuckoo filter for the wire. - Filter is serialized: The cuckoo filter is encoded as a binary blob plus metadata (bucket count, entries per bucket, fingerprint bits, and hash seed) and attached to your subscription request.
- Server matches probabilistically: For each incoming update, the server hashes the pubkey and checks whether its fingerprint exists in your filter, forwarding the update if it matches.
- Client verifies matches: Because the server-side check has a ~1% false positive rate, your client re-checks each match against its exact pubkey set before acting on it.
Filter Structure
The compressed filter is sent as part of your subscription request:
{
"cuckooAccountInclude": {
"data": "AAAAAAAAAACJdcGaAAAAAAAAAAAASsE3BAAAADxkwAAAAAAAAAAAA...",
"bucketCount": 16,
"entriesPerBucket": 4,
"fingerprintBits": 16,
"hashSeed": "8749487436367949345",
"hashAlgorithm": "SIP_HASH"
}
}
| Field | Description |
|---|---|
| data | Base64-encoded bucket data |
| bucketCount | Number of buckets in the filter |
| entriesPerBucket | Slots per bucket (typically 4) |
| fingerprintBits | Bits per fingerprint (8, 12, or 16) |
| hashSeed | Seed for the hash function (SipHash-2-4) |
| hashAlgorithm | Hashing algorithm identifier (currently SIP_HASH) |
Subscription Types
Compressed filters can be attached to two subscription types, each firing on a different event:
- Accounts Filter (
cuckoo_accounts_filter): Fires when a tracked account's own state is written to. Use this when you want to monitor account data changes. - Blocks Filter (
cuckoo_account_include): Fires when any tracked pubkey is referenced in a transaction's account keys, including program IDs used via CPI. Use this for transaction monitoring.
CompressedAccountFilterSet (from the cuckoo module in the yellowstone-grpc-proto crate) provides a method for each:
// Accounts subscription - Attaches the filter to an accounts subscription
filter.insert_into_subscribe_request(&mut request, "tracked");
// Blocks subscription - Attaches the filter to a blocks subscription
filter.insert_into_block_subscribe_request(&mut request, "tracked_blocks");
Code Example
The example below builds a cuckoo filter and attaches it to an Accounts subscription to track account-state changes for specific accounts. The server matches incoming account updates against the filter, then the client re-verifies each match against its exact pubkey set.
[package]
name = "compressed-filter-test"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
bs58 = "0.5"
futures = "0.3"
rustls = { version = "0.23", default-features = false, features = ["ring"] }
solana-pubkey = "4"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tonic = { version = "0.14", features = ["tls-native-roots"] }
yellowstone-grpc-client = "13.1.0"
yellowstone-grpc-proto = "12.4.0"
use {
futures::stream::StreamExt,
solana_pubkey::Pubkey,
std::str::FromStr,
tonic::transport::channel::ClientTlsConfig,
yellowstone_grpc_client::GeyserGrpcClient,
yellowstone_grpc_proto::{
cuckoo::CompressedAccountFilterSet,
prelude::{subscribe_update::UpdateOneof, CommitmentLevel, SubscribeRequest},
},
};
// Quicknode gRPC endpoints split into a URL (with the :10000 port) and a token.
// Example: https://docs-demo.solana-mainnet.quiknode.pro:10000 + abcde123456789
const ENDPOINT: &str = "https://your-solana-grpc-endpoint.quiknode.pro:10000";
const TOKEN: &str = "SOLANA_GRPC_TOKEN";
// Accounts to track. A cuckoo filter matches on account-data updates, so list
// accounts that actually change (mints, token accounts, oracles). The payoff
// grows with the set: it stays ~3 bytes per key, so tracking millions is cheap.
const TRACKED_ACCOUNTS: &[&str] = &[
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC mint
"Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", // USDT mint
];
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// tonic pulls in rustls 0.23, which no longer auto-selects a crypto backend;
// install one before the first TLS handshake or connect() panics.
rustls::crypto::ring::default_provider()
.install_default()
.map_err(|_| anyhow::anyhow!("failed to install rustls crypto provider"))?;
let pubkeys: Vec<Pubkey> = TRACKED_ACCOUNTS
.iter()
.map(|s| Pubkey::from_str(s).map_err(|e| anyhow::anyhow!("invalid pubkey {s}: {e}")))
.collect::<anyhow::Result<_>>()?;
// Leave headroom above the entry count so inserts don't saturate the table.
let mut filter = CompressedAccountFilterSet::with_capacity(pubkeys.len().max(100))?;
for pk in &pubkeys {
filter.insert(*pk)?;
}
println!("Tracking {} accounts via cuckoo filter", filter.len());
let mut client = GeyserGrpcClient::build_from_shared(ENDPOINT.to_string())?
.x_token(Some(TOKEN))?
.tls_config(ClientTlsConfig::new().with_native_roots())?
.connect()
.await?;
// Attach the cuckoo filter to an accounts subscription; the server matches
// incoming account updates against it.
let mut request = SubscribeRequest {
commitment: Some(CommitmentLevel::Processed as i32),
..Default::default()
};
filter.insert_into_subscribe_request(&mut request, "cuckoo_accounts");
let mut stream = client.subscribe_once(request).await?;
println!("Subscribed with cuckoo filter; waiting for matching account updates...");
while let Some(update) = stream.next().await {
match update?.update_oneof {
Some(UpdateOneof::Account(acc)) => {
let Some(info) = acc.account else { continue };
// The server-side cuckoo check has a ~1% false positive rate.
// contains() is an exact membership test, so use it to confirm.
let matched = <[u8; 32]>::try_from(info.pubkey.as_slice())
.map(|bytes| filter.contains(Pubkey::new_from_array(bytes)))
.unwrap_or(false);
if matched {
println!(
"Account update: {} (slot {})",
bs58::encode(&info.pubkey).into_string(),
acc.slot,
);
}
}
Some(UpdateOneof::Ping(_)) => println!("Ping received - connection alive"),
_ => {}
}
}
Ok(())
}
Best Practices
1. Client-Side Verification
Always re-check matches against your exact pubkey set. The server-side cuckoo match has a ~1% false positive rate, while contains checks the local exact set:
if filter.contains(incoming_pubkey) {
// This is a real match from your tracked set.
}
2. Filter Capacity
Size the filter for the number of pubkeys you track, plus a little headroom for future inserts. Capacity is the maximum number of entries the filter can hold:
let mut filter = CompressedAccountFilterSet::with_capacity(pubkeys.len().max(100))?;
3. Dynamic Updates
Cuckoo filters support O(1) insert and remove operations, so you can adjust a large set without rebuilding it:
filter.insert(new_pubkey)?;
filter.remove(old_pubkey);
filter.insert_into_block_subscribe_request(&mut request, "tracked_blocks");
4. Choose the Right Subscription Type
- Use the Accounts subscription (
cuckoo_accounts_filter) to track account state changes. - Use the Blocks subscription (
cuckoo_account_include) to track transaction references.
Requirements
- Proto version:
yellowstone-grpc-proto(v12.4.0 or later) - Client library: The Rust yellowstone-grpc-proto crate, or the @triton-one/yellowstone-grpc TypeScript client (v5.0.9 or later).
Resources
We Love Feedback!
If you have any feedback or questions about this documentation, let us know. We'd love to hear from you!