Skip to main content
Superteam Academy
+20 XP
4/15

4. Accounts Are Just Bytes

Version stamp — checked 2026-07-26. anchor-lang 1.1.2 (published 2026-06-26) · cargo-build-sbf with platform-tools v1.54 (rustc 1.89) · borsh resolves to 1.8.0 · edition 2021. The three rent constants below — ACCOUNT_STORAGE_OVERHEAD = 128, DEFAULT_LAMPORTS_PER_BYTE_YEAR = 3480, DEFAULT_EXEMPTION_THRESHOLD = 2.0 — were read out of solana-rent. The byte sizes were verified as const assertions compiled on the toolchain above.

Your program so far has no memory. Every instruction logs a line and exits. Call it a thousand times and nothing about the chain is different afterwards.

To fix that you need an account — and an account is not an object, a row, or a document. It is a byte buffer whose length you choose once, at creation, and can never change.

That single sentence is why this lesson comes before you write any code. The size decision is irreversible, it costs the payer money for as long as the account exists, and Anchor will not compute it for you unless you ask.

What the runtime actually stores

Every account on Solana carries exactly four things the runtime cares about:

FieldWhat it is
address32 bytes. How anything finds the account
owner32 bytes. The program id allowed to write the data
lamportsThe balance — and the rent-exemption deposit
dataA flat [u8] of fixed length. This is the part you design

Note what is missing: no type, no schema, no column names. data is bytes. The only reason [0, 0, 0, 0, 0, 0, 0, 0, 82, 12, …] means anything at all is that a program agrees to read it a particular way.

"Owner" is the word people trip on. The owner is a program, not a person. Your vault's owner will be your program id — that is what makes it program-controlled state rather than data sitting under somebody's wallet. Your own VaultState has a field called owner too, and that one holds the user's wallet. Two different meanings, one word, both correct, and you will read code that uses each. The runtime's owner is enforced by the runtime. Yours is enforced only by the constraints you write in module 3.

The eight bytes you did not write

Anchor puts an 8-byte discriminator at the front of every #[account] buffer. It is derived from the account's type name, so VaultState's eight bytes are not Config's eight bytes.

byte 0               8                                                          49
  │  discriminator   │  your fields, Borsh-serialized in declaration order      │
  └──────────────────┴──────────────────────────────────────────────────────────┘

Every deserialize checks those eight bytes first. Hand your program some other program's account — or your own Config where a VaultState is expected — and it fails on the discriminator instead of reading foreign bytes as if they were your struct. This is the cheapest security property in Anchor, and you get it by typing #[account].

You never read or write those eight bytes yourself. You do have to pay for them.

Deriving the vault's size, out loud

Here is the struct you wrote in Course 2, unchanged:

#[account]
#[derive(InitSpace)]
pub struct VaultState {
    pub owner: Pubkey,   // 32
    pub balance: u64,    //  8
    pub bump: u8,        //  1
}

Borsh writes fields in declaration order, back to back, with no padding and no type information. So:

  8   discriminator   (Anchor, on every #[account])
 32   owner: Pubkey   (32 raw bytes, no length prefix)
  8   balance: u64    (little-endian, 8 bytes, fixed)
  1   bump: u8        (one byte, values 0..=255)
───
 49   bytes total

49. Not "about 48", not "round it to 64". Forty-nine. You will type that as space = 8 + 32 + 8 + 1 in the next two lessons — written as a sum rather than as 49, so the reason for every term stays visible in the code.

The sizes you need for anything else:

TypeBytes
bool, u8, i81
u16 / i162
u32 / i324
u64 / i648
u128 / i12816
Pubkey32
Option<T>1 + size of T
String4 (length prefix) + bytes of content
Vec<T>4 (length prefix) + count × size of T

The last two are where fixed-size accounts and variable-length data collide: you must allocate for the maximum length you will ever accept, and then enforce that maximum in code. A String field with no length cap is an account you cannot size.

Two ways to get 49 wrong

std::mem::size_of::<VaultState>() is not the answer. Rust aligns struct fields for CPU access; Borsh does not. size_of::<VaultState>() is 48 — 41 bytes of fields rounded up to the struct's 8-byte alignment — while Borsh writes 41. Size with size_of and you allocate 56 bytes for a 49-byte account, and overpay rent on every vault for as long as it exists, for no benefit. Rust's memory layout and Borsh's wire layout are different problems that happen to be about the same struct.

Counting the discriminator twice, or not at all, is the other one. 8 + 32 + 8 + 1 counts it once. If you ever see a space that equals exactly the sum of the field sizes, that account is 8 bytes short, and the first write past the field boundary fails.

The maintainable form is the one your Course 2 file already set up:

space = 8 + VaultState::INIT_SPACE   // 8 + 41 = 49

#[derive(InitSpace)] generates INIT_SPACE as the sum of the field sizes — without the discriminator, which is why the 8 + is still yours to write. Add a field later and the constant follows; a literal sum does not. Ship INIT_SPACE. We write the sum in this course because you are learning what it is made of.

Why the size is fixed, and what it costs

The runtime allocates space bytes at creation, and that is the account's size for life. There is a separate realloc mechanism for growing an account later — a deliberate operation with its own payer and its own constraints, not something that happens because your struct grew.

The account also has to be rent-exempt to survive, which means holding a lamport balance above a minimum that scales with size:

minimum = (128 + space) × 3480 × 2 lamports

128 bytes is the per-account storage overhead the runtime charges regardless of your data, 3,480 lamports is the per-byte-year price, and 2 is the exemption threshold in years. For the vault:

(128 + 49) × 3480 × 2  =  177 × 6960  =  1,231,920 lamports  ≈  0.00123 SOL

That deposit is not a fee. It sits in the account and comes back if the account is ever closed. But it is the payer's SOL, locked, for as long as the account exists — which is why over-allocating has a price tag and "just make it 1024 bytes to be safe" is a decision, not a precaution. Those three numbers are protocol parameters, so the way to read the current value rather than trust a number in a tutorial is the getMinimumBalanceForRentExemption RPC method.

One thing that is not a cost: ongoing rent collection. It is gone. An account's rent_epoch field is vestigial — if you find prose describing it as "when rent is next due", that prose is describing a mechanism that no longer runs.

What Anchor stopped asking for

Search for account-creation examples and you will find this shape:

#[account(init, payer = user, space = 49, rent = rent)]

rent = rent was removed in Anchor 0.31. There is no rent constraint, and no rent: Sysvar<'info, Rent> field to add alongside it. Anchor reads the rent parameters itself and computes the deposit from space. Any tutorial still passing it predates 0.31 by definition — which is a useful dating signal for everything else on that page.

What you do still supply, every time, is space. And now you know what each term in it pays for.

Question 1 of 4

Your `VaultState` is `owner: Pubkey`, `balance: u64`, `bump: u8`. You are writing the `space` for its `init` constraint. What number does it have to come to, and why that number?

AI Partner unlocks after the quiz — 0/4 answered

PreviousNext