The Moment I Realized I Did Not Know What Was in My Own Account

I am staring at a hex dump of one of my own program's accounts on a block explorer and I cannot read it. I wrote the struct. I deployed the program. I called the instruction that initialized this account. And yet the bytes in front of me look like alphabet soup. Eight bytes of what looks like noise, then something that might be a u64 if I squint, then more bytes I cannot match to any field I declared.

This is the kind of moment that makes you question whether you actually understand the framework you are building on. I have been writing Anchor programs for weeks now. I can describe what #[account] does at a high level — it makes a struct serializable to and from raw account data. But describing something at a high level is not the same as being able to predict, byte for byte, what shows up on chain. And in MEV work, I cannot afford to not know. If I am parsing a competing program's accounts to read its state — pool reserves, vault balances, oracle prices — I need to know exactly where each field lives.

So today I sit down and force myself to actually understand it.

The Layout in One Sentence

After spending the morning reading the official docs and a couple of solid third-party walkthroughs, here is the layout in one sentence: an Anchor account is eight bytes of discriminator followed by your struct's fields, in declaration order, serialized via Borsh, with no padding and no metadata.

That sentence has a lot in it. Let me unpack it the way I unpacked it for myself, because each piece tripped me up at least once.

Why Eight Bytes of Discriminator

The first eight bytes of every Anchor account are not your data. They are a fingerprint Anchor stamps onto the account so that, when someone later tries to deserialize the bytes into a Rust struct, the framework can verify the struct type matches what is actually stored.

According to the Anchor documentation on program structure, the discriminator is computed as SHA256("account:" + StructName), with the first eight bytes taken as the result. So a struct named Counter produces a discriminator equal to SHA256("account:Counter")[0..8]. Note the colon and the lack of any space — the input is a literal string with the prefix "account:" glued directly to the struct's identifier. Per the official Anchor Program Structure docs, this fingerprint is checked on every deserialization: "the first 8 bytes of account data is checked against the discriminator of the expected account type."

This is a type-safety guard, and it is a strong one. Without it, an attacker (or a buggy client) could pass an account of type User where the program expected Vault, and as long as the byte length matched, the deserialization would succeed and silently produce nonsense. With the discriminator, the deserializer rejects the wrong type before any field is touched.

Why hashing instead of a simple sequential counter? I went hunting for the original design rationale and found it in the very first issues of the Anchor repository. In a January 2021 GitHub issue, the framework's creator explained that the hash-based approach lets account structs be defined anywhere in the project — not just inside the #[program] module — and lets the discriminator be computed at compile time inside the macro. "There is no runtime cost vs a 1-indexed counter," the issue notes. The aesthetic and ergonomic gains are free.

This design choice has a side effect that took me a while to internalize: renaming a struct breaks all existing accounts of that type. Because the discriminator is derived from the struct's name, the bytes burned into every existing account are tied to the literal identifier you chose. Rename Counter to EventCounter and every old account is now unreadable by the new program — the deserializer will see the old discriminator and reject it as a type mismatch. This is a footgun the size of a Buick. I am writing it on a sticky note and putting it on my monitor.

The Same Trick for Instructions

The discriminator pattern is not limited to accounts. Anchor uses the same construction for instructions, just with a different prefix. According to the official IDL docs, instruction discriminators use "global:" + instructionName as the input string. An instruction named initialize produces SHA256("global:initialize")[0..8], which the docs spell out concretely as [175, 175, 109, 31, 13, 152, 155, 237].

This matters for parsing. Every Anchor instruction's data field starts with these eight bytes. If I am sniffing a transaction and trying to figure out which instruction was called on which program, the first eight bytes of the instruction data are a free lookup key. I do not need to guess what the call was — I can hash all of the program's known instruction names with the "global:" prefix, build a table, and match against it. The same applies to accounts: hash every known struct name with "account:" and you have a table mapping fingerprints to types.

This is the kind of thing that changes how you think about parsing on-chain data. Instead of writing fragile heuristics, you build dictionaries.

Then Come the Fields

After the eight discriminator bytes, your struct's fields are written out one after the other, in the exact order you declared them, using Borsh.

Borsh — the Binary Object Representation Serializer for Hashing, originally built by the team behind NEAR — is a non-self-describing binary format. According to the official Borsh specification, this means the byte stream contains zero type metadata. There are no tags, no field names, no length headers for the struct itself. The serializer assumes the deserializer already knows the schema. If you have the struct definition, you can read the bytes. If you do not, the bytes are gibberish.

This is a sharp contrast with formats like JSON, where a field's name travels with its value. Borsh is more like the wire format for a private API call between two services that already share a protobuf — except even more minimal, because there are no field tags. The format is also deterministic: the spec guarantees a bijective mapping between an object and its bytes. The same struct always produces the same bytes; the same bytes always decode to the same struct. There is exactly one canonical representation. This determinism is critical for hashing — which is, after all, the H in Borsh.

For primitives, the encodings are about as simple as they can be. Per the Borsh spec, integers are little-endian, with u8 taking one byte, u16 two, u32 four, u64 eight, and u128 sixteen. A bool takes one byte: 0x01 for true, 0x00 for false. A Pubkey takes thirty-two bytes (it is just a 32-byte ed25519 public key, written raw).

A walkthrough on Solana Borsh serialization gives concrete byte examples that finally made it click for me. The number 42 as a u64 is [0x2A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]. Little-endian means the least significant byte comes first, which still trips me up after years of working with big-endian network protocols. But once you accept the convention, the layout becomes mechanical: read the type, read its size, advance the cursor.

Where It Gets Subtle: Variable-Length Types

Fixed-size primitives are the easy part. Variable-length types — strings, vectors, options — are where the layout gets interesting and where you need to be careful with space calculations.

A String in Borsh is encoded as a u32 length prefix followed by the UTF-8 bytes. The string "hi" is [0x02, 0x00, 0x00, 0x00, 0x68, 0x69] — four bytes for the length (two, little-endian), then the two ASCII bytes for h and i. Total: six bytes for a two-character string. The four-byte length overhead is fixed; the actual payload scales with the string.

A Vec<T> follows the same pattern: a u32 length prefix giving the number of elements, then each element serialized in turn. A Vec<u32> with three elements takes 4 + 3×4 = 16 bytes total. A Vec<Pubkey> with ten elements takes 4 + 10×32 = 324 bytes. The element type only needs to be known at compile time; the count is in the bytes.

Fixed-size arrays [T; N], by contrast, have no length prefix at all. Since the size is part of the type, the deserializer already knows how many elements to expect. A [u8; 32] is just thirty-two bytes back to back, no header. This is one of those subtle distinctions that changes your space math: switching from Vec<u8> to [u8; 32] saves four bytes and any allocation overhead.

Option<T> is one byte plus, optionally, the inner type. None is a single 0x00 byte. Some(T) is a 0x01 byte followed by the serialized T. So Option<u64> on chain is either one byte (None) or nine bytes (Some) — never the sixteen bytes you might assume from Rust's in-memory layout.

Enums are one byte for the variant ordinal plus the variant's own serialized data. The official Anchor space reference gives the rule for sizing: 1 + size_of(largest_variant). The discriminant is always one byte, so the framework rounds up to the largest variant to ensure any value fits in the allocated space.

The Trap That Cost Me a Real Bug

Reading the size table from a deep-dive on Anchor account sizing is what saved me from a bug I almost shipped. The post puts Rust's std::mem::size_of next to Anchor's on-chain Borsh space side by side, and the differences are not subtle.

A Vec<T> in Rust memory is twenty-four bytes — pointer, capacity, length, eight bytes each. On chain, it is four bytes plus the actual element data. A String in Rust memory is also twenty-four bytes for the same reason. On chain, it is four bytes plus the UTF-8 payload.

If you naively use std::mem::size_of::<MyStruct>() to compute the space you need to allocate when initializing an account, you are computing the wrong number. You are computing how big the struct is in your program's RAM, not how big it is when serialized to disk. For a struct full of Vecs and Strings, the Rust size and the Borsh size can be wildly different in either direction.

This is exactly the kind of mistake that produces bugs which only manifest later. You over-allocate and waste rent, or worse, you under-allocate based on the wrong sizing assumption and your write fails the first time the data grows beyond the declared cap.

The Anchor team's answer to this is the #[derive(InitSpace)] macro. According to the official account space reference, it generates an INIT_SPACE constant on the struct that adds up the Borsh sizes correctly. For variable-length fields, you annotate them with #[max_len(N)] to declare the upper bound — the maximum number of elements for a Vec, or the maximum byte length for a String. The macro does the addition.

One catch the docs are explicit about: INIT_SPACE does not include the eight-byte discriminator. You always have to add that yourself: space = 8 + MyStruct::INIT_SPACE. I have already forgotten this once today and watched the deserializer reject my account because it was eight bytes too short. The error message was unhelpful enough that I stared at it for ten minutes before remembering.

What Anchor Actually Validates

Both the official deserialization path and an account-reading guide agree on a critical and slightly uncomfortable truth: when Anchor deserializes account data, it validates the discriminator and that there are enough bytes for the struct's declared size. It does not — and cannot — validate that the bytes between offset 8 and offset 8 + N actually form a meaningful instance of the struct.

If the bytes are corrupted or were written by a buggy version of the program, Borsh will happily decode them into Rust values. A bool field whose byte is 0x05 does not raise an error in many implementations — it is just whatever the implementation does with non-canonical bool values. A Vec whose u32 length prefix says "two billion elements" will try to allocate space for two billion elements before failing.

This is one reason zero-copy accounts (which I will get to) often replace bool with u8. With u8, every byte is a valid value. With bool, the program can technically encounter a value outside {0, 1} and the behavior is implementation-defined. In a deterministic environment like a Solana program, even "unlikely" undefined behavior is not acceptable.

The practical consequence: do not treat successful deserialization as proof that the data is sane. Validate fields after decoding. Range-check numbers. Bound-check vector lengths against your application's reasonable maximums. The discriminator tells you the writer thought it was writing your type. It does not tell you the writer was correct.

The Other Layout: Zero-Copy and repr(C)

Not every Anchor account uses Borsh. The framework provides a separate #[zero_copy] macro for accounts that need to be read without an intermediate deserialization step — which matters when the account is large enough that copying it into a fresh struct would consume a meaningful share of compute units.

The official zero-copy docs describe #[zero_copy] as expanding into #[repr(C)] plus the bytemuck Pod and Zeroable derives, which together let the framework treat the raw account bytes as a memory-mapped view of the struct. There is no decode step. The struct is the bytes.

This changes the layout rules entirely. repr(C) follows the C ABI's alignment rules, which means the compiler may insert padding between fields to align each field on a multiple of its size. A writeup on Solana zero-copy deserialization gives a concrete example I am going to keep on hand: a struct with a u8, then a u64, then another u8 does not produce ten bytes on chain. It produces twenty-four. The first u8 is followed by seven bytes of padding so the u64 can sit at offset 8. The trailing u8 is followed by another seven bytes of padding so the total struct size is a multiple of the largest field's alignment, which is eight.

For anyone coming from the Borsh side, this is a shock. Borsh has no padding. Move from #[account] to #[zero_copy] without rethinking your layout and your byte counts will be completely different. The discriminator is still the first eight bytes — that part does not change — but everything after it follows different rules.

Zero-copy also disallows the variable-length types entirely. No Vec, no String, no Option. Everything must be fixed-size, because the layout has to be known at compile time for the pointer cast to work. If you want a list, you use a fixed-size array and a length counter. If you want a string, you use a fixed-size byte array and zero-pad the unused tail. The trade-off is exactness in exchange for compute savings, and on a large account it is worth taking.

For my MEV work, this distinction is going to matter the moment I start parsing pools from production DEXs. Some of them use Borsh-style accounts. Others use zero-copy with explicit repr(C) layouts. I cannot use one parser for both.

Building a Mental Model

If I had to reduce all of this to a checklist I could run in my head before writing or reading any account, it would be this:

First, identify which serialization the account uses. Borsh by default with #[account], C ABI with #[zero_copy]. The two layouts are not interchangeable. Reading one with the wrong assumption produces garbage.

Second, account for the eight-byte discriminator. It is always there. It is always first. It is always derived from SHA256("account:" + StructName)[0..8]. Skip it on read; allocate for it on write.

Third, walk the fields in declaration order. For Borsh, advance the cursor by each field's serialized size — primitive size for fixed types, four bytes plus payload for variable types, one byte plus payload for Option, one byte plus largest variant for enums. For repr(C), account for padding between fields based on alignment.

Fourth, when sizing space for init, use InitSpace and max_len rather than size_of. The Rust in-memory size and the on-chain serialized size are not the same number for any struct that contains heap-allocated data.

Fifth, do not trust the bytes after deserialization. Discriminator validation is a type guard, not a sanity check. Validate field values explicitly.

What This Changes for the Bot

The immediate practical use of this for my project is parsing third-party accounts. Every DEX I integrate publishes its account types — pool state, vault state, oracle state — as Anchor structs (or close cousins of them). To read those accounts in real time and decide whether they represent an arbitrage opportunity, I need to extract specific fields from raw bytes faster than the next bot.

Having the layout in my head means I can write parsers that go straight to the byte offset of the field I need rather than deserializing the entire account. If I only need two fields from a 400-byte account, I can read those two fields and ignore the rest. The compute and latency savings add up.

It also means I can be skeptical of the accounts I read. Just because a pool account decodes does not mean its values are sensible. Reserves of zero, oracle timestamps from the future, fee tiers outside the documented range — these are all things I have seen on chain, and the discriminator did not catch any of them. The discriminator gets me to the door. From there, validation is on me.

I have been treating Anchor as a black box that converts structs to bytes and back. After today, I have a much clearer picture of what is inside the box. It is not magic. It is eight bytes of fingerprint, then a deterministic byte stream with no padding and no labels, written in the order I declared the fields. That description fits on an index card, and once I had it, the hex dumps stopped being alphabet soup.

Key Takeaways

  • Every Anchor account starts with an eight-byte discriminator computed as SHA256("account:" + StructName)[0..8]. Renaming a struct breaks every existing account of that type.
  • Anchor uses the same hash trick for instructions with the prefix "global:", which makes parsing transactions much easier — hash known names, build a lookup table, match the first eight bytes.
  • Borsh is non-self-describing, deterministic, and has no padding. Fields are written in declaration order with no metadata between them, and integers are little-endian.
  • Variable-length types in Borsh use a four-byte u32 length prefix (Vec, String) or a one-byte tag (Option, enums). Fixed-size arrays carry no length prefix at all.
  • Rust's std::mem::size_of and the on-chain Borsh size are different numbers for any struct containing heap-allocated types; use #[derive(InitSpace)] and add the eight-byte discriminator yourself.
  • #[zero_copy] accounts use repr(C) instead of Borsh, which means alignment-driven padding and no Vec/String/Option/bool — a completely different layout that happens to share only the eight-byte discriminator with the default case.

Disclaimer

This article is for informational and educational purposes only and does not constitute financial, investment, legal, or professional advice. Content is produced independently and supported by advertising revenue. While we strive for accuracy, this article may contain unintentional errors or outdated information. Readers should independently verify all facts and data before making decisions. Company names and trademarks are referenced for analysis purposes under fair use principles. Always consult qualified professionals before making financial or legal decisions.