One Account Type to Rule Them All: Anchor V2's Slab and the Duplicate-Mutable Check

Anchor V2 threw out its zoo of account wrappers and kept exactly one. Everything you load now, a plain struct, a growable list, an SPL mint, is the same generic type wearing different clothes, and the rule that stops an attacker from passing one account into two mutable slots went from N pairwise comparisons to a single bitmask test. Read those two designs side by side and you can see precisely where Solana memory-safety and account-aliasing bugs live, including one that is rejecting valid transactions in the current tree.

I read account models for a living, and the fastest way to audit a framework is to find the one type every load funnels through and then ask what invariant it is quietly enforcing. In V2 that type is Slab. This is a teardown of how it reads and writes accounts, how the borrow-state protocol underneath it keeps two views of the same account from handing out conflicting references, and how the duplicate-mutable defense built on top of it works, fails, and gets fixed.

Diagram comparing the header-only and length-prefixed Slab byte layouts chosen at compile time by the tail element type

One account type: Slab<H, T>

A V1 program carried several wrapper types: Account for a deserialized struct, AccountLoader for zero-copy data, and hand-rolled patterns when neither fit. V2 collapses all of it into one generic, Slab<H, T>, with two knobs. H is the header type, your #[account] struct or an external layout like an SPL mint. T is the tail element type, and it defaults to a marker called HeaderOnly.

That default is the whole trick. A plain Account<Counter> is literally Slab<Counter, HeaderOnly>. The same type that holds a fixed struct also holds a growable, length-prefixed array the moment you give it a real element type for T. Learning Slab is learning how V2 touches accounts, because there is nothing else to learn.

// A plain account is just a Slab with a zero-sized tail marker.
pub type Account<H> = Slab<H, HeaderOnly>;

// A growable account names a real, Pod element type for the tail.
pub type OrderBook = Slab<BookHeader, Order>;   // [disc][header][len][orders..]

The elegant part is how V2 stops you from calling tail methods on a header-only account. HeaderOnly deliberately does not implement Pod, the bytemuck trait that marks a type as plain old data where every bit pattern is valid. The tail methods (push, len, as_slice, try_push) live in an impl block gated on T: Pod. Call .push() on a header-only Account<T> and you get a compile error, method not found, not a runtime surprise.

impl<H, T: Pod> Slab<H, T> {       // gated on T: Pod
    pub fn push(&mut self, item: T) -> Result<()> { /* ... */ }
    pub fn len(&self) -> usize { /* ... */ }
}
// HeaderOnly: not Pod -> push() does not exist on Account<H>. Compile error, not UB.

The type system, not a runtime branch, keeps header-only accounts from pretending they have a tail. That is the first thing to internalize: in V2 the account's capabilities are encoded in its type parameters, and the compiler is the first line of the audit.

The byte layout is decided at compile time

A Slab has two physical shapes, and which one you get is chosen at monomorphization by whether T is zero-sized. A header-only account is just the 8-byte discriminator followed by the header. A real-tail account adds a u32 length, padding to the tail's alignment, then the packed items.

ZST tail   (Account<H>):    [disc:8][H]
Pod tail   (Slab<H,Entry>): [disc:8][H][len:u32][pad to align(Entry)][items..]

Every offset is a const, folded at compile time, not computed per call. HEADER_OFFSET is H::DATA_OFFSET, which is 8 for a native #[account] struct (the 8-byte discriminator Anchor writes at the front) and 0 for an SPL type that has no Anchor discriminator. LEN_OFFSET is HEADER_OFFSET + size_of::<H>(). ITEMS_OFFSET is LEN_OFFSET + 4 rounded up to align_of::<T>(). None of this costs a runtime multiply.

Capacity is the one number that is never stored. It is recomputed from the account's live data length every single time, through a free const fn that refuses to underflow.

const fn capacity_for(data_len: usize, items_offset: usize, elem: usize) -> usize {
    // An external realloc can shrink the buffer below items_offset.
    if data_len < items_offset { return 0; }     // saturating, never wraps
    (data_len - items_offset) / elem
}

That guard is not theoretical. A CPI into the system program can realloc an account smaller while you still hold a Slab over it. If capacity were a stored field it would now be a lie. By deriving it from the current data_len and saturating at zero instead of wrapping a usize subtraction into something near 2^64, the slab can never report room that does not exist. I have seen stored-capacity assumptions survive three audits and then get blown open by a realloc nobody traced, so a model that recomputes the bound is the safer default.

The in-memory struct stays deliberately tiny: a view into the account, a cached header_ptr, and an is_mutable flag. The length pointer, items pointer, and capacity are intentionally not cached, so the footprint matches the pre-rewrite Account<T> and deep instruction trees do not pay extra stack bytes at every load site. Cheap to construct, no hidden state to fall out of sync.

The layout is a compile-time fact, not a runtime computation, and the only quantity read live is the one an attacker can actually change.

The borrow-state protocol underneath every load

This is the part worth slowing down on, because it is where the memory-safety bugs would live. Solana hands every account a one-byte borrow-state field, and the pinocchio runtime gives that byte a protocol that replaces the Rc<RefCell<...>> dance from solana-program with a single counter, which is where a lot of the compute-unit savings come from.

The encoding is three states in one byte. 255 (NOT_BORROWED) means free. 0 means exclusively, mutably borrowed. Any value below 255 and above 0 is a decrementing counter, N shared borrows outstanding. Slab participates in this protocol so two views of the same account cannot hand out conflicting references.

Diagram of the borrow-state byte transitions across the read path, the write path, and drop for an account

The read path, from_ref, is the careful one. It rejects if the borrow state is below 2, meaning the account is already mutably borrowed (0) or has no shared slot left (1), returning AccountBorrowFailed. Otherwise it validates the header (owner plus discriminator) and registers a shared borrow by writing borrow_state - 1.

fn from_ref(view: &AccountView) -> Result<Slab<H, T>> {
    let st = view.borrow_state();
    if st < 2 { return Err(AccountBorrowFailed); }   // 0 = mut held, 1 = no shared slot
    H::validate(view)?;                               // owner + discriminator
    view.set_borrow_state(st - 1);                    // register a shared borrow
    Ok(Slab { /* is_mutable: false */ })
}

Additional read-only loads are still fine, because they only ever alias &H, never &mut H, and DerefMut panics on a read-only slab so you cannot sneak a write through. The write path, build_mutable, derives the header pointer through data_mut_ptr() to preserve write provenance, then sets the borrow state to 0.

Why go through data_mut_ptr() and not the cheaper data_ptr()? Because of how Rust's aliasing models treat pointer derivation. Under Stacked Borrows and its successor Tree Borrows, a pointer carries provenance, a derivation history that decides what it is allowed to do, not just an address. Derive your header pointer from a *const and later write through it and you have written through a pointer that never had write permission, which is undefined behavior the optimizer is free to miscompile. Deriving from data_mut_ptr() keeps the write provenance intact.

fn build_mutable(view: &AccountView) -> Result<Slab<H, T>> {
    let ptr = view.data_mut_ptr();        // keep WRITE provenance (not *const)
    H::validate(view)?;
    view.set_borrow_state(0);             // exclusive: any copied view gets nothing
    Ok(Slab { header_ptr: ptr, is_mutable: true })
}

Drop closes the loop. A mutable slab resets the byte to NOT_BORROWED; a shared slab increments the counter back up. Dropping a load_mut'd slab is literally what frees the account for the next use. The recent hardening that prevents a safe read-only load from aliasing a live mutable slab is exactly this protocol tightening: from_ref now refuses to alias an account that build_mutable has set to 0. And is_mutable is the field that turns a forgotten #[account(mut)] into a clean DerefMut panic at the point of access instead of silent corruption through a const-provenance pointer.

Here is the audit antenna. Any path that obtains a header reference without going through from_ref or build_mutable, a borrow_unchecked after a CPI, or a Slab retained across a realloc, is exactly where the borrow-state invariant can be violated. Those seams are where I point my probe tests first.

The borrow-state byte is the real account lock, and every safe load either decrements it or zeroes it, so two conflicting references can never coexist.

SlabSchema and SlabInit: the header does the rest

Slab does not know how to validate or create your specific header. It delegates to two traits on H, which is how the same chassis serves both an Anchor account and an SPL token account. SlabSchema supplies DATA_OFFSET, MIN_DATA_LEN, and validate. A blanket impl covers any type that is both Owner and Discriminator with the standard offset-8, owner-check, discriminator-check.

impl<T: Owner + Discriminator> SlabSchema for T {
    const DATA_OFFSET: usize = 8;                 // 8-byte Anchor discriminator
    fn validate(view: &AccountView) -> Result<()> {
        require_keys_eq!(view.owner(), &T::owner());          // right program
        require!(view.discriminator() == T::DISC, BadDiscriminator);
        Ok(())
    }
}

An SPL Mint or TokenAccount overrides this with DATA_OFFSET = 0 and its own field checks, because it has no Anchor discriminator. The other trait, SlabInit, supplies create_and_initialize: the blanket impl does the system-program create plus the discriminator write, and SPL types override it with a token-program CPI. So the entire difference between "Anchor account" and "SPL account" is two trait implementations. Slab is the body, H supplies validation and creation. When you read an expanded V2 program, every Account<T> field in try_accounts becomes a Slab::from_ref for a read or a Slab::build_mutable for a mutable load, and an init field routes through H::create_and_initialize.

One chassis, two hooks, and the account's identity rules live entirely in the header type.

Why duplicates are dangerous, and how the runtime helps

A Solana instruction is just a list of account addresses, and nothing stops a caller from passing the same address twice, once as from and once as to. If both slots are #[account(mut)], the program ends up with two live mutable handles to the same bytes. That is the engine behind classic infinite-mint and double-credit bugs: you debit one alias and credit the other, both pointing at the same balance, and the books still appear to balance. This is a well-documented Solana vulnerability class, and the canonical writeups all end at the same fix.

In V1 that fix was manual: one key == other.key() comparison per pair of mutable accounts, usually spelled as an Anchor constraint like #[account(constraint = a.key() != b.key())]. Miss one pair and you have a hole. V2 replaces all of it with a single test per instruction, and it gets most of the work for free because the runtime already deduplicates accounts.

The BPF loader serializes the program input with a one-byte marker per account. A non-duplicate carries a NON_DUP sentinel (0xff); a duplicate carries the index of the earlier account it aliases, any value from 0 to 254. This is the same field the runtime uses so it does not copy the same account into the input buffer twice. V2's account cursor reads that marker as it walks. The first account can never be a duplicate. For the rest, a marker byte means read straight from the buffer, otherwise resolve the view from the lookup array and record the alias.

On every duplicate, the cursor lazily allocates an AccountBitvec, 256 bits as [u64; 4], one bit per account (a transaction caps at 255 accounts), and sets two bits: the current index and the index it aliases. Transactions with no duplicates never allocate the bitvec at all, so the common path pays nothing. The bitvec exposes one operation that matters, intersects(mask), a four-word AND that is nonzero if any set bit overlaps the mask.

The runtime hands the program a precise, free record of which account slots are aliases of each other, and V2 only has to ask the right question of it.

The compile-time MUT_MASK and one intersects test

Diagram showing the runtime duplicate bitvec AND-ed against the compile-time mutable mask to detect aliasing

Each #[derive(Accounts)] struct gets a const MUT_MASK: [u64; 4] computed at compile time on its TryAccounts impl. Bit i is set if and only if account-view index i is a non-Option, mutable field without unsafe(dup). Two deliberate exclusions: unsafe(dup) fields opt out, because the author explicitly allows the alias, and Option<T> mutable fields are excluded from the static mask and handled separately. Hold onto that second exclusion. It is the bug.

A nested child folds its whole sub-mask into the parent, shifted to the child's base offset, so a nested account tree lands every mutable slot on the correct global bit. The dispatcher then does the entire duplicate-mutable check once, before any per-field loading runs.

let (views, duplicates) = loader.walk_n(T::HEADER_SIZE);
if let Some(dups) = duplicates {                 // None when no alias materialized
    if dups.intersects(&T::MUT_MASK) {           // one 4-word AND across the tree
        return Err(ConstraintDuplicateMutableAccount);
    }
}

That outer Some short-circuits when no duplicate ever materialized. When MUT_MASK is all zeros, no mutable fields anywhere, the intersect const-folds away entirely at inline time. One AND-and-test replaces N pairwise key comparisons across the whole struct tree, including nested children, and it cannot miss a pair because it does not enumerate pairs. It tests a bitmask. The runtime told it which slots are aliases; the compiler told it which slots are mutable; the overlap is the bug, and the overlap is one instruction.

This is the redesign in one sentence: aliasing detection became data, not control flow, and the data is exact. The only way to get it wrong now is to compute the mask wrong, which is exactly what happens next.

The live bug: two or more omitted optional mutable accounts

The static MUT_MASK cannot see two things. Optional mutable accounts need a dynamic mask, because a None field has no bit until it resolves to Some. So the struct also carries an active_mut_mask(&self) that starts from MUT_MASK and ORs in each optional mutable field's bit only when it resolved to Some. Trailing remaining-accounts need a re-check, because the header walk only sees declared accounts, so a walk_remaining re-tests the bitvec against the active mask after each trailing account. Both of those are correct. The optional path around them is not.

Here is how "None" reaches the program. An omitted optional account is sent by the client as the program's own ID. Omit two optional accounts and both slots carry the same address, so the loader marks the second as a duplicate of the first and the cursor sets both their bits in the AccountBitvec. Two absent accounts now look exactly like two aliased present ones.

For an optional mutable field, the derive emits a per-field guard that is supposed to fire only when the field is present.

// VULNERABLE: emitted per optional mut field, but NOT gated on Some.
if let Some(__dups) = __duplicates {
    if __dups.get((__base_offset + offset) as u8) {   // <-- fires even when field is None
        return Err(ConstraintDuplicateMutableAccount);
    }
}

The intent, stated in the derive's own comment, is that a None slot stays silent because the check sits inside the field's if let Some(field) wrapper. It does not. The dup-check is stored as a separate node on the parsed field model, not merged into the field's constraints, so the per-field if let Some(ref mut field) wrapper never wraps it. All the dup-checks get aggregated at struct level under a single gate that only asks "did the transaction contain any duplicates at all?" The per-field __dups.get(offset) then runs for an account that is actually absent.

Trace it end to end. Omit two optional mutable accounts, both encoded as the program ID, both bits set, __duplicates is Some, and the ungated __dups.get(offset) fires for an account that is not there. A perfectly valid transaction that legitimately skips its optionals gets rejected with ConstraintDuplicateMutableAccount. This is not an exploit, it is a correctness and denial-of-service bug, and it silently breaks any instruction with two or more optional mutable accounts whenever a caller omits them.

The fix is one condition: gate each optional dup-check on the field actually being present.

// FIXED: only check the alias when the optional field resolved to Some.
if let Some(field) = &field {
    if let Some(__dups) = __duplicates {
        if __dups.get((__base_offset + offset) as u8) {   // now unreachable when absent
            return Err(ConstraintDuplicateMutableAccount);
        }
    }
}

Either gate the check on field.is_some(), or genuinely move it inside the per-field if let Some(...) wrapper the comment already claims contains it. The clean change lives at the dup-check construction site, where the node is built, not where it is emitted.

A defense that derives its mask wrong is worse than no defense, because it fails closed on honest users while looking correct in every single-optional test.

Where this generalizes

The shape of both designs is the same, and it is the thing to carry into your own reviews. V2 turned two runtime decisions into compile-time data: the account's capabilities became type parameters on Slab, and aliasing detection became a const bitmask AND-ed against a runtime bitvec. That style is strictly safer when the data is computed correctly, and strictly more dangerous when it is not, because the bug moves out of the code path you can step through and into a mask you have to derive on paper.

So when you audit a framework that has replaced checks with tables, do not test the happy path, test the table. For Slab, that means hunting the loads that skip from_ref and build_mutable, the borrow-state byte being the real lock. For the duplicate-mutable check, it means writing the one test almost nobody writes: call the instruction with two optional mutable accounts omitted, and watch a valid transaction get rejected. The mask is only as good as the bit that sets it, and the absent bit is the one that bites.

References

  1. Anchor, Zero Copy. https://www.anchor-lang.com/docs/features/zero-copy
  2. Chainstack, Solana Zero-Copy Deserialization: AccountLoader Deep Dive (bytemuck Pod, repr(C), discriminator). https://chainstack.com/solana-zero-copy-deserialization-accountloader/
  3. pinocchio, AccountInfo (docs.rs). https://docs.rs/pinocchio/latest/pinocchio/account_info/struct.AccountInfo.html
  4. Helius, How to Build Solana Programs with Pinocchio. https://www.helius.dev/blog/pinocchio
  5. Ralf Jung, Stacked Borrows: An Aliasing Model for Rust. https://www.ralfj.de/blog/2018/08/07/stacked-borrows.html
  6. Ralf Jung, From Stacks to Trees: Tree Borrows. https://www.ralfj.de/blog/2023/06/02/tree-borrows.html
  7. Rust RFC 3559, Rust has Provenance. https://rust-lang.github.io/rfcs/3559-rust-has-provenance.html
  8. solana-labs/solana, bpf_loader serialization (duplicate-account marker). https://github.com/solana-labs/solana/blob/master/programs/bpf_loader/src/serialization.rs
  9. solana-labs/solana, issue #14691 (account serialization copies). https://github.com/solana-labs/solana/issues/14691
  10. Helius, A Hitchhiker's Guide to Solana Program Security (duplicate mutable accounts). https://www.helius.dev/blog/a-hitchhikers-guide-to-solana-program-security
  11. etherfuse/solana-course, Duplicate Mutable Accounts. https://github.com/etherfuse/solana-course/blob/main/content/duplicate-mutable-accounts.md
  12. anchor-lang (docs.rs), account constraints. https://docs.rs/anchor-lang/latest/anchor_lang/
  13. Helius, Solana vs Sui Transaction Lifecycle (Sealevel account locks). https://www.helius.dev/blog/solana-vs-sui-transaction-lifecycle