Security and production
Secure-by-default patterns, production builds, and performance tuning for v2 programs.
Secure by default
Production builds
Performance and optimizations
Custom entrypoint
Raw account access
Secure by default
Safety defaults that turn common bugs into compile errors or validation failures.
Anchor tries to make the safe path the ordinary path. Some checks still happen at runtime, but several common bug classes become compile-time errors or explicit opt-ins.
This page maps the defaults a normal v2 program inherits. It does not replace an audit, but it should clarify which problems Anchor catches before your handler logic runs.
The main defaults are:
Account<T>requiresT: Podand a no-padding layout. If a field would make zero-copy unsound, the program fails to compile instead of silently reading ambiguous bytes.- duplicate mutable aliases are rejected during account validation. This protects handlers from accidentally receiving the same account in two mutable fields.
CpiHandle<'a>ties CPI account handles to Rust borrows of the typed wrappers they came from. That lets Anchor use pinocchio’s unchecked CPI path while preventing ordinary typed aliasing at compile time.
Use BorshAccount<T> for data that cannot be Pod.
The duplicate-account escape hatch is named unsafe(dup) on purpose. Use it only when the handler is written to avoid conflicting mutable references.
UncheckedAccount still does no validation. Pair it with address, owner, constraint, or handler-side checks before trusting the account.
#[account(address = config.authority)]pub authority: UncheckedAccount,This version is not audited. Treat these defaults as a better baseline, not as a substitute for review, fuzzing, and production-specific threat modeling.
Production builds
Feature-flag and release considerations for production programs.
Keep the default anchor-lang-v2 features while developing. They preserve diagnostics and runtime support that matter while tests, fuzzers, and reviewers are still exercising the program.
Production builds are the last step, not the first. Start with the defaults, measure the program, then remove features only once you know which checks or helpers it no longer needs.
anchor-lang-v2 = { git = "https://github.com/otter-sec/anchor.git", branch = "anchor-next" }The default features are alloc, guardrails, and account-resize. guardrails catches runtime misuse such as wrong program id dispatch or mutable access to a read-only account. account-resize enables realloc_account and the pinocchio hook that tracks original account lengths. Disable it only if the program has no realloc paths.
A minimal profile looks like:
anchor-lang-v2 = { git = "https://github.com/otter-sec/anchor.git", branch = "anchor-next", default-features = false, features = ["alloc"] }Only use that after tests, fuzzing, and review have shown the removed guardrails are not covering active mistakes. Disabling guardrails saves a small amount of binary size and CU, but removes diagnostics that are valuable during testing.
Two opt-in features need extra production care. const-rent skips the runtime rent sysvar call by baking the rent formula into the binary. It is off by default because a future Solana rent-formula change would make the baked constants stale until the program is rebuilt and redeployed. idl-build is a build-time feature for IDL emission and should not be included in deployed SBF binaries.
Performance and optimizations
Where Anchor's binary and CU savings come from.
Anchor’s performance gains are cumulative. No single change explains the full result. The savings come from many smaller choices stacking up.
For application developers, the takeaway is simple. Write normal Anchor code first, then reach for lower-level options only after tests and measurements show where the program spends binary size or compute.
The main sources are:
- the pinocchio and
#![no_std]runtime path - a smaller core derive that delegates account behavior to traits the compiler can optimize
Account<T>casting account bytes directly into Pod-backed fixed-size state- macro-time PDA resolution when seeds are byte literals
- runtime PDA validation that can skip repeated on-curve checks for non-empty program-owned PDA accounts
- typed
CpiHandle<'a>values that let Anchor use pinocchio’s unchecked CPI path without ordinary typed mutable aliasing - wincode instruction and event encoding with a borsh-compatible wire configuration
#[event(bytemuck)]for fixed-size events that can be emitted with a discriminator write plus one body copy
Optional flags can reduce a program further when their tradeoffs are acceptable. const-rent removes a rent sysvar read from account creation. Disabling guardrails and account-resize can reduce size and CU for programs that do not need those safety nets.
Two lower-level escape hatches go further when a single instruction justifies the cost. A custom entrypoint skips Anchor’s dispatcher for a chosen instruction. Raw account access drops the typed wrapper to read and write account bytes directly. Both give up checks Anchor normally runs, so reach for them only with measurements and tests in hand.
The checked-in bench programs are under bench/programs/. See Examples and benchmarks for the current table and reproduction commands.
Custom entrypoint
Skip Anchor's dispatcher for one hot instruction with a one-byte discriminator and a hand-written entrypoint.
Every instruction starts with a discriminator. The leading bytes of the instruction data tell the program which handler to run. By default the #[program] macro hashes the handler name into an 8-byte discriminator, emits the entrypoint, and matches those bytes in a dispatch table.
When one instruction dominates call volume and does very little work, that dispatch becomes a meaningful share of its budget. Two levers cut it down, and they stack:
On the prop-amm bench program, an assembly fast path lands update at 26 CU. See Examples and benchmarks for the table.
When to reach for this#
This pays off for a handler that runs often and does almost nothing. An oracle update, a price tick, a transfer between two known accounts. The fixed cost of reading the discriminator, walking accounts, and running try_accounts can outweigh the handler’s own work.
For everything else, the default dispatch is already cheap. Reach for this only with a benchmark that shows the dispatch cost.
One-byte discriminators#
#[discrim = N] overrides the default 8-byte discriminator with a single byte. The macro still emits the entrypoint, allocator, panic handler, and dispatch table. They match on N instead of the hash:
#[program]pub mod oracle { use super::*;
#[discrim = 0] pub fn update(ctx: &mut Context<Update>, new_price: u64) -> Result<()> { // ... Ok(()) }
#[discrim = 1] pub fn initialize(ctx: &mut Context<Initialize>) -> Result<()> { Ok(()) }}The mode is all-or-nothing. Once one handler sets #[discrim = N], every handler needs a one-byte value, and duplicates fail at compile time. The tests-v2/programs/spl program uses this across its whole surface, numbered #[discrim = 0] through #[discrim = 54].
Taking over the entrypoint#
To skip the dispatcher entirely on a hot path, own the entrypoint. This needs two changes on top of a standard v2 program:
- Add
no-entrypointto the crate’sdefaultfeatures. The macro then stops emitting its entrypoint, allocator, and panic handler, and exposes__anchor_dispatchas a symbol you can call. - Reinstate the allocator and panic handler, then provide your own
entrypoint:
#[cfg(target_os = "solana")]anchor_lang_v2::pinocchio::default_allocator!();#[cfg(target_os = "solana")]anchor_lang_v2::pinocchio::default_panic_handler!();
#[cfg(target_os = "solana")]#[no_mangle]pub unsafe extern "C" fn entrypoint(input: *mut u8, ix_data_ptr: *const u8) -> u64 { let ix_data_len = *(ix_data_ptr.sub(8) as *const u64) as usize; if ix_data_len == 0 { return crate::__anchor_dispatch(input, ix_data_ptr); } match *ix_data_ptr { // Fast path for `#[discrim = 0]`. Walk `input` for accounts, read // args past `ix_data_ptr.add(1)`, write, and exit. 0 => 0, // Everything else runs the typed handler. _ => crate::__anchor_dispatch(input, ix_data_ptr), }}__anchor_dispatch takes the same (input, ix_data_ptr) pair the loader hands your entrypoint, so the fallback is one forward call.
The assembly route goes further. Hand-written sBPF with offsets against agave’s account layout is what lands prop-amm’s update at 26 CU. The full version is at bench/programs/prop-amm/anchor-v2/src/asm/entrypoint.s. Reach for assembly only when the saved compute is worth maintaining offset tables against runtime changes.
What the fast path gives up#
Warning (A fast-pathed instruction runs none of Anchor's checks)
Skipping __anchor_dispatch skips the generated TryAccounts work for that discriminator: account count, duplicate-mutable checks, owner, writable, and signer flags, data-length checks, PDA derivation, and address comparisons. Write each check you still need by hand, or use pinocchio’s typed AccountView for the ones it covers.
- The
#[discrim = N]values are part of your ABI now. They keep clients, the IDL, and your entrypoint agreeing on one byte. - The fallback depends on
__anchor_dispatch’s(input, ix_data_ptr) -> u64signature. Re-check it on Anchor upgrades. - Assembly offsets are pinned to agave’s serialization layout. A runtime layout change means editing the assembly, and the compiler only sees raw numbers.
Clients, CPI callers, and tests that exercise the built .so keep working unchanged. The entrypoint branches on the discriminator regardless of caller, so fast-path discriminators take the inline path and everything else runs the typed handler.
Raw account access
Drop the typed Account<T> wrapper and operate on pinocchio's AccountView bytes when a handler only touches a field or two.
Account<T> validates the owner, discriminator, and size on load, then gives you a typed view. For a handler that reads one field, writes one field, and exits, that load can be the dominant cost.
Pinocchio’s AccountView is the raw layer underneath. It exposes the account’s address, lamports, data, and owner with no checks attached. Every safety check becomes something you write or knowingly skip.
Warning (borrow_unchecked_mut is unsafe)
borrow_unchecked_mut skips the runtime borrow bookkeeping that try_borrow_mut performs. Two live &mut to the same account data is undefined behavior, and the borrow checker cannot catch it once the references come out of a raw slice.
The hybrid pattern#
You rarely need to go fully raw. The practical shape keeps Anchor’s entry validation and drops to bytes only for the hot write. #[derive(Accounts)] still runs the owner, writable, and discriminator checks, then you pull the underlying AccountView through .view():
#[derive(Accounts)]pub struct Tick { #[account(mut)] pub feed: Account<PriceFeed>,}
pub fn tick(ctx: &mut Context<Tick>, new_price: u64) -> Result<()> { // Validation already ran: `feed` is writable, owned, and correctly // discriminated. Drop to raw bytes only for the write. unsafe { let mut view = *ctx.accounts.feed.view(); let data = view.borrow_unchecked_mut(); data.get_unchecked_mut(16..24).copy_from_slice(&new_price.to_le_bytes()); } Ok(())}This keeps every entry-time check and gives up only the typed write. You owe correct field offsets and the guarantee that no other mutable borrow of the account is live.
Going fully raw#
A program that drops #[derive(Accounts)] owns every check itself. The pinocchio helloworld bench program shows the pattern end to end. For that workload, typed v2 already measures cheaper than the pinocchio hand-roll, so raw access is not an automatic win. Measure before committing to it.
When you do go raw, put back each check Anchor would have run:
An init path can lean on create_account_signed, which enforces the PDA match and the program owner as it creates the account. A read-after-init path has no such helper, so re-check owner and discriminator by hand.
See Account data model for the typed wrappers this sidesteps.