Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.chainstack.com/llms.txt

Use this file to discover all available pages before exploring further.

TLDR:
  • Solana accounts store raw bytes — to read them, you need to know how the owning program serialized its data.
  • Borsh is the canonical, deterministic, cross-language format used by Anchor and most clients.
  • Bincode is the Rust-only format the validator has historically used internally; it is now unmaintained and being phased out.
  • Wincode is Anza’s bincode-compatible replacement with zero-copy reads, already shipping inside Agave — it is the reason recent gRPC streams are faster.

Why serialization matters on Solana

A Solana account is just an owner, some lamports, and an opaque byte array in its data field. The runtime does not know or care what those bytes mean — the program that owns the account decides how to lay them out. When you call getAccountInfo or getMultipleAccounts against your Chainstack node, you get those bytes back as base64. Turning them into a typed struct is deserialization, and you have to use the same format the program used to write them. Three serialization formats dominate the Solana ecosystem, and they are not interchangeable on the wire:
  • Borsh — the canonical format for on-chain program data and clients.
  • Bincode — the format the validator uses internally for its own wire protocol and snapshots.
  • Wincode — Anza’s high-performance, bincode-compatible successor.
Picking the wrong one gives you garbage, not an error, so it pays to know which is which.

Borsh

Borsh — Binary Object Representation Serializer for Hashing — is deterministic and canonical: a given value always serializes to exactly one byte sequence, and two different values can never produce the same bytes. That property is why it is the default for on-chain account data and why it has a formal, language-agnostic specification with implementations in Rust, TypeScript, Python, Go, C++, and more. It is the default in Anchor and across the SPL. Integers are little-endian, dynamic containers (Vec, String) use a 4-byte length prefix, and enum variants use a single-byte discriminant. Use Borsh whenever your data crosses a language boundary — for example, a TypeScript or Python indexer reading account data from your Chainstack node.

Bincode

Bincode is a Rust-only binary format with no formal specification and no maintained cross-language implementations. The Solana validator has historically used it for its internal wire protocol (gossip, repair, shreds), transaction serialization, and on-disk snapshots.
Bincode is unmaintained as of RUSTSEC-2025-0141 (January 7, 2026). The advisory recommends migrating to Wincode. Avoid bincode for new code — you will still encounter it in existing Solana data structures, but the ecosystem is actively moving off it.

Wincode

Wincode is a serialization library from Anza, the team behind the Agave validator. It was built to replace bincode on the validator’s hot paths, and it is byte-for-byte wire-compatible with bincode’s default configuration — so it is a drop-in performance upgrade, not a new protocol. Its headline feature is true zero-copy deserialization: for fixed-layout #[repr(C)] structs of primitive fields, you get typed references directly into the original byte buffer with no allocation and no copy. That is exactly the pattern you want when parsing high volumes of raw account data, such as in a Geyser pipeline. Wincode is audited by Neodyme, OtterSec, and Asymmetric Research, and it is the format RUSTSEC-2025-0141 points to as the bincode successor. Because it matches bincode, Wincode writes 8-byte length prefixes for Vec and String, where Borsh writes 4-byte prefixes — so Borsh-encoded and Wincode-encoded bytes are not interchangeable. Decode data with the same library that wrote it.

Decode account data from your Chainstack node

The most common task is reading account data your program wrote with Borsh. First, get a Solana RPC endpoint:
  1. Sign up with Chainstack.
  2. Deploy a node.
  3. View node access and credentials.
Then fetch the account and deserialize its bytes. The schema must match your on-chain struct’s field order exactly.
import * as borsh from "borsh";
import { Connection, PublicKey } from "@solana/web3.js";

// Schema must match the on-chain struct field-for-field, in order.
const schema = {
  struct: {
    amount: "u64",
    nonce: "u32",
  },
};

const connection = new Connection("YOUR_CHAINSTACK_ENDPOINT");
const pubkey = new PublicKey("YourProgramAccountAddressHere");

const accountInfo = await connection.getAccountInfo(pubkey);
if (!accountInfo) throw new Error("Account not found");

// web3.js returns account data as a Buffer, already base64-decoded.
const decoded = borsh.deserialize(schema, accountInfo.data) as {
  amount: bigint;
  nonce: number;
};

console.log(decoded.amount); // u64 values come back as BigInt
console.log(decoded.nonce);
Anchor programs prepend an 8-byte discriminator (a hash of the account name) to the data. Skip the first 8 bytes before deserializing, or use the Anchor client, which handles it for you.

Not everything is Borsh: SPL Token accounts

A common trap: SPL Token accounts are not Borsh-encoded. The Token program uses the Solana-native Pack trait — a fixed-size, C-style layout (a token account is exactly 165 bytes, with the owner at offset 32). This is why memcmp filters work at fixed byte offsets. Trying to Borsh-decode a token account will fail or return nonsense. You have two clean options:
  • Pass encoding: "jsonParsed" to getAccountInfo and let your Chainstack node return a structured JSON object with mint, owner, and tokenAmount.
  • Decode the binary layout client-side with @solana/spl-token, which uses fixed-offset buffer layouts, not Borsh.
TypeScript
import { AccountLayout } from "@solana/spl-token";
import { Connection, PublicKey } from "@solana/web3.js";

const connection = new Connection("YOUR_CHAINSTACK_ENDPOINT");
const info = await connection.getAccountInfo(new PublicKey("TokenAccountAddressHere"));
if (!info) throw new Error("Account not found");

const token = AccountLayout.decode(info.data); // fixed 165-byte layout, not Borsh
console.log(token.amount); // bigint
console.log(new PublicKey(token.owner).toBase58());
The rule of thumb: Anchor and most custom programs use Borsh; the Token program and other native programs use packed Pack layouts. When in doubt, jsonParsed lets the node do the work for well-known programs.

Serialize and deserialize in Rust

On the program and validator side, all three formats are Rust libraries doing the same job. Here is the same struct round-tripped through each.
Cargo.toml
borsh = { version = "1", features = ["derive"] }
bincode = { version = "2", features = ["derive"] }
wincode = { version = "0.5", features = ["derive"] }
use borsh::{BorshSerialize, BorshDeserialize, to_vec, from_slice};

#[derive(BorshSerialize, BorshDeserialize, PartialEq, Debug)]
struct Transfer {
    amount: u64,
    nonce: u32,
}

let original = Transfer { amount: 1_000_000, nonce: 42 };

let bytes: Vec<u8> = to_vec(&original)?;
let decoded: Transfer = from_slice(&bytes)?;
assert_eq!(original, decoded);
To serialize straight into a destination instead of allocating an intermediate Vec, use wincode::serialize_into(dst, src) — note the writer comes first. To serialize Solana’s Pubkey (address) with Wincode, enable the wincode feature on solana-pubkey or solana-address; it is off by default.

Zero-copy reads with Wincode

For high-throughput parsing, Wincode can hand you a typed reference straight into the byte buffer — no allocation, no copy. There is no ZeroCopy derive: derive SchemaRead (and SchemaWrite) on a #[repr(C)] struct whose fields are all zero-copy eligible, and the impl is generated for you. Add #[wincode(assert_zero_copy)] to make the compiler reject the type at definition if it is not eligible.
Rust
use wincode::{SchemaWrite, SchemaRead, ZeroCopy};

// Layout matches a token-like account: mint (32), owner (32), amount (8).
#[derive(SchemaWrite, SchemaRead)]
#[wincode(assert_zero_copy)]
#[repr(C)]
struct TokenAccountData {
    mint: [u8; 32],
    owner: [u8; 32],
    amount: u64,
}

// `use wincode::ZeroCopy` brings from_bytes / from_bytes_mut into scope.
// Read a typed reference into the buffer — zero allocations, zero copies.
let account: &TokenAccountData = TokenAccountData::from_bytes(raw)?;
println!("{}", account.amount);

// Or borrow mutably and edit the buffer in place.
let account: &mut TokenAccountData = TokenAccountData::from_bytes_mut(raw_mut)?;
account.amount += 500; // writes directly into the underlying bytes
Zero-copy is strict about layout. A type qualifies only if it is #[repr(C)] (or #[repr(transparent)]), contains no String, Vec, or Option, and has no implicit padding. Padding is the subtle trap: a struct of two u64 fields followed by a single u8 is not eligible, because the trailing byte leaves the struct misaligned — you would have to pad it out to the next 8-byte boundary. Anything variable-length, like a String or Vec, can never be zero-copy, since its size is not known from the type alone. In practice you rarely make a whole account zero-copy. The pattern is to keep one full struct for the complete data and a separate, slimmer #[repr(C)] view for the hot path. A counter bump, for example, reads and writes only the counter through the zero-copy view — skipping the full deserialize-then-reserialize, which is where the largest compute savings come from.

Which format should you use?

BorshBincodeWincode
Maintained byNEARUnmaintainedAnza
Primary use on SolanaOn-chain data, clients, AnchorValidator internals (legacy)Validator internals (current)
Cross-languageYes (TS, Python, Go, more)No (Rust only)No (Rust only)
Deterministic and canonicalYesVersion-dependentYes, for fixed layouts
Zero-copy readsNoNoYes (#[repr(C)] structs)
Formal specYesNoNo (bincode-compatible)
StatusStable, recommendedRetired (RUSTSEC-2025-0141)Active, replacing bincode
In practice:
  • Building an on-chain program, or a client or indexer that reads its data — use Borsh. Anchor gives you this by default, and it keeps your TypeScript and Python tooling interoperable.
  • Writing a performance-critical native program or low-level tooling that parses raw account bytes at volume — reach for Wincode and its zero-copy reads.
  • Maintaining existing code that uses bincode — plan a migration to Wincode. The wire formats are compatible for default types, so the move does not break data already on disk or in flight.

Performance and Geyser streams

Wincode’s payoff is in compute units (CU) on-chain and CPU cycles in the validator. The community febo/tide benchmark measures the CU cost of reading and updating one field of a 160-byte, token-like account on-chain. The numbers are workload-specific, but the ordering is consistent and dramatic:
ApproachCU
transmute (theoretical floor)35
Wincode (zero-copy)35
Wincode (standard)62
Bincode 2214
Borsh617
Bincode 18,127
This is not just an on-chain concern. Anza moved the validator’s hot serialization paths to Wincode in Agave 3.1 and broadened it in Agave 4.0. Independent benchmarks attribute single-digit-millisecond latency improvements on account and transaction gRPC streams to the change. If you run Geyser workloads against Chainstack Trader Nodes or Yellowstone gRPC, this lands as lower stream latency as the fleet runs newer Agave — with no change required on your side.
Wincode does not change how you consume Geyser data. The Yellowstone gRPC boundary is Protocol Buffers, so your subscriber code is unaffected. The benefit is faster validator-internal serialization, which shows up as lower latency. The raw account.data you receive is still encoded by whichever program owns the account — so the decoding rules above (Borsh, Pack, and so on) still apply.

Get your own node endpoint today

Start for free and get your app to production levels immediately. No credit card required.You can sign up with your GitHub, X, Google, or Microsoft account.

Additional resources

Last modified on May 29, 2026