All articles
Solana·June 4, 2026·6 min read

How Light Protocol Compresses Solana State With ZK Proofs

How Light Protocol's ZK compression uses state trees and validity proofs to cut Solana account storage costs by orders of magnitude.

The problem Light Protocol is actually solving

Every account you create on Solana costs rent — a one-time deposit sized to cover roughly two years of storage, which in practice everyone treats as permanent since almost nobody reclaims it. A bare SPL token account is 165 bytes and needs about 0.00203928 SOL to become rent-exempt. That number looks small until you multiply it by volume: an airdrop to 500,000 wallets means creating 500,000 token accounts, which is over 1,000 SOL locked up before a single token moves. Validators also have to hold all of that state in memory or on fast NVMe to keep read latency low, and state growth has been flagged repeatedly by Anza and Firedancer engineers as one of the harder long-term scaling constraints on the network — worth keeping in mind if you're also digging into how Firedancer's validator design compares to Agave's.

Light Protocol's answer is: stop putting the data in the accounts database at all. Put a cryptographic commitment to the data on-chain, keep the actual bytes in the ledger (which Solana already stores and replicates), and use a ZK proof to convince the runtime that a given piece of off-chain data is legitimate without re-uploading it every time.

Commitments instead of accounts

The core structure is a state tree — a Merkle tree where each leaf is a hash of a compressed account's contents. Instead of the account living in the global accounts database, only the tree's root (32 bytes) sits in an on-chain account owned by Light's system program. Everything else — the raw account data, ownership info, lamport balance — is written into the transaction's instruction data, which lands in the ledger but never touches the accounts database.

This isn't a new idea on Solana; it's the same trick Metaplex's Bubblegum program used for compressed NFTs, built on the spl-account-compression program. Light generalizes it beyond NFTs to arbitrary program state and to SPL-token-like compressed tokens.

A naive Merkle tree breaks the moment two people try to update it in the same slot, because inserting a new leaf changes the root, invalidating any proof generated against the old root. Light's state trees are concurrent Merkle trees: they keep a changelog buffer of the last N root transitions, so a proof generated a few updates ago can still be replayed and validated against the current root as long as it's within that window. This is what lets multiple unrelated transactions touch the same tree in the same block without constantly failing with stale-root errors.

Why you don't send 26 sibling hashes per transaction

A tree deep enough to hold ~68 million leaves needs a depth of 26. A raw Merkle inclusion proof at that depth is 26 hashes, 32 bytes each — over 800 bytes just for the proof, before you count the account data itself. That's expensive in a 1232-byte transaction and gets worse as the tree grows.

Instead of shipping the raw path, Light generates a zk-SNARK validity proof (Groth16) that attests "this leaf, with this data, is a member of the tree with this root" — and that proof is a fixed ~128–256 bytes regardless of tree depth. Verification happens on-chain using Solana's alt_bn128 syscalls (the BN254 pairing operations added specifically to make on-chain Groth16 verification cheap), typically costing somewhere in the 100k–200k compute unit range rather than the cost of hashing 26 levels manually.

The proof itself isn't generated by the validator or the on-chain program — it comes from an off-chain prover service. Light runs this as part of its stack (a Go/gnark-based prover), and an indexer called Photon tracks the full state of every tree so wallets and dApps can ask "give me a valid proof for this compressed account" without maintaining their own copy of the tree. If you're already building real-time consumers of Solana state, this indexer dependency will feel familiar — it's the same category of problem as streaming account updates over Yellowstone gRPC, just applied to proof generation instead of raw account diffs.

State updates work like UTXOs, not like mutable accounts

When you "update" a compressed account, you're not mutating a leaf in place. You nullify the old leaf (add its hash to a nullifier set so it can never be spent again) and append a new leaf representing the new state. This is closer to a UTXO model than to Solana's normal mutable-account model, and it's the reason double-spend protection doesn't require locking the whole tree — the nullifier check is a cheap set-membership test.

A worked example: token accounts at scale

Say you're distributing a token to 200,000 wallets, a fairly ordinary airdrop size for a mid-size Solana project.

Uncompressed: each recipient needs an associated token account. 200,000 × 0.00203928 SOL ≈ 408 SOL in rent alone, plus you're writing 200,000 new entries into the global accounts database permanently.

Compressed: each recipient gets a leaf in a shared state tree. There's no per-account rent — you pay a small state tree rent (amortized across potentially millions of leaves in that tree) plus normal transaction fees for the compress instructions. Light's own benchmarks put compressed token accounts at roughly 100x–5000x cheaper than the equivalent SPL account, depending on how many leaves you're batching per transaction.

In code, using Light's stateless.js SDK, a compressed transfer looks conceptually like:

import { CompressedTokenProgram } from '@lightprotocol/compressed-token';
import { Rpc, createRpc } from '@lightprotocol/stateless.js';

const rpc: Rpc = createRpc(RPC_URL, COMPRESSION_RPC_URL);

const ix = await CompressedTokenProgram.transfer({
  payer: payerPubkey,
  inputCompressedTokenAccounts: await rpc.getCompressedTokenAccountsByOwner(sender, { mint }),
  toAddress: recipientPubkey,
  amount: transferAmount,
  recentInputStateRootIndices,
  recentValidityProof, // fetched from the Photon RPC endpoint
});

The validity proof is fetched from the RPC just before the transaction is built, since it has to be fresh relative to the tree's changelog window — this is a real operational detail, not a footnote, because a proof that goes stale mid-flight means a failed transaction and a retry.

Where this actually matters, and where it doesn't

Compression is a clear win for write-heavy, read-light state: airdrops, loyalty points, on-chain game inventories, order records, anything you create in bulk and query occasionally. It's a worse fit for state you need to read and mutate constantly inside a hot instruction path, since every touch requires a fresh proof round-trip to an indexer rather than a direct account read. If you're bundling a large batch of compress/decompress instructions to land reliably in one slot — common in pump.fun-style volume and bundling setups — you'll also want to think about landing guarantees the same way you would with Jito bundles versus priority fees, because a partially-landed compression batch leaves you with orphaned nullifiers to clean up.

It also introduces a hard dependency on indexer availability. If Photon (or whatever indexer you're running) falls behind or goes down, you can't generate proofs and your compressed state becomes temporarily unreadable even though the ledger data is all still there. Teams building wallet balance tools or portfolio trackers on top of compressed tokens need to budget for that the same way they would for any indexing pipeline behind a wallet tracking service.

If you're evaluating whether compression fits your program's storage model, or you need the indexing and RPC infrastructure to actually consume compressed state reliably, that's the kind of build our infra and data pipeline work is set up to handle.

Need a bot like this built?

We design, build and run trading bots on Solana, Hyperliquid and Polymarket.

Start a project
#Solana#Light Protocol#ZK compression#state trees#compressed accounts