Core Concepts
Discriminator layout (raw bytes)
Pina injects discriminator bytes as the first field of every #[account], #[instruction], and #[event] native schema. PinaPod then generates a separate zero-copy storage view with the same discriminator-first wire layout.
At runtime the parser checks the exact schema size and discriminator, delegates recursive content validation to PinaPod, and returns the generated TypeZc view under the runtime’s borrow guard.
offset | size | meaning
------ | ---- | -------
0 | N | discriminator (N = BYTES of enum primitive: 1/2/4/8)
N | ... | payload fields
This contract is what enables:
- deterministic
Type::SIZEchecks, - zero-copy validation with
as_account()/try_from_bytes(), - alignment-one storage fields generated by PinaPod.
Why this is safer than implicit external headers
External fixed-size headers require separate offset logic in each parse path. With an auto-injected first field, the PinaPod derive owns one schema and one validated storage layout. Pina does not manually cast the native schema or expose its object representation.
Discriminator width and compatibility
The enum primitive width controls both on-chain layout and migration surface.
- Width is set on the discriminator enum using
#[discriminator(primitive = u8)](defaultu8). - Allowed widths are
u8,u16,u32, andu64. - The maximum practical width is capped at 8 bytes for zero-copy safety.
Discriminators and ABI migrations
| Change | Compatibility impact |
|---|---|
| Add a new discriminator variant | Backward-compatible; existing routes keep their identity |
| Change an existing discriminator value | Breaking for every historical byte slice |
| Change a migration-aware account or enveloped instruction payload | Compatible only when the checked-in history has an adjacent transition |
| Change a published versioned event schema | Compatible; a new version is appended and clients decode each version with its own schema |
| Change a published snapshot-only instruction payload | Breaking; create a new instruction discriminator |
| Append optional accounts to an instruction route | Compatible when the existing positional list remains an identical prefix |
| Reorder, remove, or escalate an instruction slot | Breaking; create a new instruction discriminator |
| Change the migration version width after release | Breaking for every enveloped wire contract |
Add migrations to an account, instruction, or event attribute to opt one contract into a framework-owned version field, or opt whole contract kinds in when you record the history:
pina migrations create --auto true # or --auto accounts,events,instructions
--auto accepts true, false, or a comma-separated list of accounts, events, and instructions. pina migrations create records the policy in migrations/manifest.json, its only home, and snapshots every contract of the listed kinds; a later run without the flag keeps the recorded policy. pina.toml holds no migration policy. Macros read the policy from the manifest, so a new struct still fails the build with “run pina migrations create” until it has a snapshot. Once a policy is recorded, create also scaffolds a build.rs emitting cargo:rerun-if-changed=migrations/manifest.json, so flipping the policy re-expands every contract without editing source. Add migrations = false to keep one contract out of an auto policy; removing an envelope the manifest already records is an error instead of a silent opt-out, because stripping an envelope is itself a wire-format change.
The policy gives accounts and events the version field. It records each instruction as a snapshot without one: the payload keeps its [discriminator][payload] wire format, and the build fails when the struct drifts from the snapshot. Add migrations to an #[instruction] to give that instruction the version field and adjacent transitions instead; the #[discriminator(entrypoint)] dispatcher then converts an older payload to the current layout before the handler runs.
Pina places the version field immediately after the discriminator. The accepted encodings are u8, u16, and u32; u8 is the default and the recommended choice. Versions are tracked per contract, not per program: each account, instruction, and event owns an independent history that starts at version 0, so u8 gives every contract its own 255-version budget. Rewriting one contract 255 times is not a realistic outcome, and the narrower field costs one byte in every enveloped account. Choose a wider encoding with pina migrations create --version-type u16 (or u32) before the first release only when you expect a single contract to exceed 255 versions. The width is program-wide, recorded as versionType in the manifest, and freezes at the first published release: after that the flag fails instead of widening it. Any other value, including u64, is rejected with an error naming the supported widths. Discriminator width is a separate setting, and that one does support u64.
Run pina migrations create before a release. Pina updates the replaceable draft when the current version is unpublished. After pina deploy records a non-local publication, the next schema change creates a new version, with an adjacent transition for an account or an enveloped instruction; the payload of a published snapshot-only instruction cannot change. Normal builds run pina migrations check and fail on drift, incomplete manual transitions, or changed published code.
An old instruction can omit only newly appended optional accounts. Pina does not synthesize signers, writable privileges, PDAs, or required accounts. Any change to an existing process slot requires a new discriminator.
Historical events are immutable, so Pina versions events instead of migrating them. The program emits only the current version. Generated clients decode each earlier version with its own schema, as a separate <Event>V<n> event, and a log record whose version no generated event describes fails instead of being misread.
Discriminator layout decision matrix
The discriminator strategy determines byte layout, parser guarantees, and cross-protocol compatibility.
| Goal | Recommended layout |
|---|---|
| Keep layout minimal and zero-copy while staying explicit | Current Pina model: discriminator bytes are the first field inside #[account], #[instruction], and #[event] structs. |
| Preserve compatibility with existing Anchor-account payloads (SHA-256 hash prefixes) | Legacy adapter model: custom raw wrapper types parse/write the existing 8-byte external prefix before converting to typed structs. |
| Minimize account size growth when you have many types | Use u8 (default) discriminator width. |
| You need more than 256 route variants | Use u16 / u32 / u64 by setting #[discriminator(primitive = ...)]. |
| Avoid schema migrations across existing serialized data | Keep existing field order and discriminator values; only append fields. |
Raw discriminator width by use-case
| Width | Max variants | Storage cost (bytes) | Recommended when |
|---|---|---|---|
u8 | 256 | 1 | Most programs and instructions |
u16 | 65,536 | 2 | Medium-large routing tables and explicit version partitioning |
u32 | 4,294,967,296 | 4 | Very large enums, rarely needed |
u64 | 18,446,744,073,709,551,616 | 8 | Legacy interoperability shims or reserved growth |
- Discriminator width only affects the first field bytes.
- Widths above 8 are rejected at macro expansion time.
- Wider discriminators improve variant space, but increase CPI payload and account rent by the exact number of bytes.
- These widths describe discriminators only. The migration version envelope is a separate setting and accepts
u8,u16, oru32— neveru64.
Zero-copy account models
#[account] and #[instruction] keep the declared Rust type as a native schema and derive a separate PinaPod TypeZc storage view. Checked loaders validate the complete byte slice before returning that in-place view. The native schema itself is never reinterpreted as bytes.
Closed macro-generated schema grammar
Pina’s audited zero-copy boundary is the code generated by #[account], #[instruction], and #[event]. Those macros accept only representations whose alignment, size, initialization, and bit validity Pina can prove:
- native integer scalars and
bool, - Pina’s
PodU*,PodI*, andPodBoolwrappers, - Pina’s exact
Addresstype, [u8; N]with a literal length,[T; N]typed arrays whereTis any other fixed grammar type andNis a literal length; storage is[PodT; N]little-endian with no length prefix, and validation recurses per element. Nested arrays such as[[u8; 4]; 2]compose,String<N>andPodString<N, PFX>with literal capacities and prefix widths,Vec<T, N>andPodVec<T, N, PFX>whereThas a fixed audited representation,Option<T>whereThas a fixed audited representation,- with the
floatsfeature:f32andf64, stored as the bit pattern of their backing little-endian integer, and fixed-pointFixedI*<Frac>/FixedU*<Frac>types from the pinnedfixedcrate. See Float and Fixed-Point Fields.
The fixed grammar is recursive through arrays, String, Vec, and Option. PinaPod fully initializes collection capacity and validates each active nested element. The macros still reject generics, arbitrary custom ZcField mappings, char, NonZero*, and representations whose alignment or bit validity Pina cannot prove.
Compact accounts use a narrower top-level grammar because tails need generated offset and patch logic. See Compact Accounts for the accepted forms.
Direct PinaPod derives and manual PinaAccount or PinaPodFixed implementations remain available for advanced integration, but they are outside Pina’s audited macro-generated contract. Implementing PinaPodFixed is unsafe; authors must uphold PinaPod’s bit-validity, alignment, initialization, size, validation-order, and aliasing invariants.
The optional crate = ... macro argument is path configuration for renamed dependencies. It must resolve to Pina itself, or to a transparent re-export of the same crate; it is not an extension point for substituting another derive or trait implementation universe.
Account validation chains
Validation methods on AccountView are composable and preserve the receiver type:
#![allow(unused)]
fn main() {
account.assert_signer()?.assert_writable()?.assert_owner(&program_id)?;
}
A chain that starts with &AccountView stays shared, while a chain that starts with &mut AccountView stays mutable. This keeps writability explicit without losing access to as_account_mut() later.
Use assert_program() when you explicitly validate a program account. Static .invoke() and .invoke_signed() builders encode their program ID. Pinocchio Token’s .invoke_with_program() and .invoke_signed_with_program() methods validate their supplied ID with Program::verify(). Neither form needs a preceding account assertion.
If you call .invoke_with_unverified_program() or .invoke_signed_with_unverified_program(), validate the exact supplied account first against a const or immutable static whose type contains no interior mutability, or an unmodified local alias of one. An expected ID supplied through instruction data is attacker-controlled and does not authenticate the target. A const or static whose type contains UnsafeCell — including a const of reference type aliasing an interior-mutable static — can be rewritten at runtime and is rejected as provenance for the same reason. Prefer Pina’s assert_program(), propagate assertion failure, and call the method directly. The lint requires validation on every continuing path. You can bind or chain from the account value returned by the assertion. Success-side Result callbacks, assignments, mutable borrows, &mut self method calls, and closures that may replace the validated binding invalidate the proof. The lint also rejects storing an unverified CPI method as a function value because doing so hides the target argument from its local proof.
When you need sysvar data, prefer Pinocchio’s checked typed loaders:
#![allow(unused)]
fn main() {
let clock = Clock::from_account_view(clock_account)?;
let rent = Rent::from_account_view(rent_account)?;
let instructions = Instructions::try_from(instructions_account)?;
}
These loaders validate the sysvar address while parsing. Their results can flow through normal Rust extraction, adapters, tuples, patterns, and control flow without extra assertions. Pina instead rejects constructors that do not validate identity. These include the Clock and Rent byte constructors, Instructions::new_unchecked, and SlotHashes::new or new_unchecked. Call these constructors directly. Storing one as a function value is also rejected so the unvalidated source remains visible.
Keep assert_sysvar() for identity-only checks and deliberate raw data access. A raw-access proof must call Pina’s method with the matching pina_sdk_ids::sysvar::<name>::ID. For a generic binding such as epoch_sysvar, the recognized canonical ID supplies the otherwise missing identity. Enforce its Result on every continuing path. You can bind or chain from the account value returned by the assertion. Success-side Result callbacks, assignments, mutable borrows, &mut self method calls, and closures that may replace the asserted binding invalidate the proof. Reviewed manual parsing also needs a narrow lint allowance on its constructor.
Typed account conversions
Traits in crates/pina/src/impls.rs provide typed conversion paths from raw AccountView values into strongly typed account states. as_account() returns Ref<T> and as_account_mut() returns RefMut<T> borrow guards. The type aliases LoadedAccount<'a, T> and LoadedAccountMut<'a, T> are provided for Ref<'a, T> and RefMut<'a, T> respectively, offering a more descriptive name for guard-backed typed account access.
A fixed account with a stored #[pda(bump = ...)] generates Type::load_pda and Type::load_pda_mut. These methods combine typed account validation with stored-bump address validation and return the same guard types. Prefer them over an assert_type → Type::assert_seeds → as_account* sequence when the handler immediately needs the state; the sequence recursively validates bounded fields more than once.
Use this order for fixed accounts:
- Use generated
load_pdaorload_pda_mutfor a stored-bump PDA when the handler needs typed fields. - Use
as_accountoras_account_mutfor another fixed account when the handler needs typed fields. - Use
assert_typeonly when the handler needs to validate an existing fixed account without loading its fields.
All three paths validate the owner, discriminator, exact account size, and active nested values. assert_type releases its data borrow before returning. It is therefore a moment-in-time validation, not a guard for a later raw cast or mutation. Do not call it before either typed-loader path.
A compact account with a stored bump generates two closural loaders. Type::with_stored_bump_pda validates ownership, the compact representation, and the address the stored bump derives before it runs the closure, using a single derivation. Type::with_checked_pda searches for the canonical bump instead and additionally rejects a stored bump that is not canonical, which is what catches a shadow account created at a noncanonical bump; prefer it when an untrusted caller chooses which account the handler loads. The runtime data borrow remains active for the closure in both, so the compact view cannot outlive its validated bytes. Keep assert_compact_type and generated assert_seeds for validation-only paths that do not need the compact view.
Account cursors
AccountsCursor is the runtime layer used by #[derive(Accounts)]. It advances through the account slice from left to right and rejects writable aliases for mutable accounts parsed individually through next_mut(), without heap allocation. Alias checks look forward: a mutable field must not reappear in any later slot. A readonly field followed by a mutable field for the same account is accepted, because an authority that signs readonly and also pays is one account in two slots, and the runtime marks both slots writable. When two fields must be distinct accounts, compare their addresses explicitly instead of relying on declaration order. Accounts are compared by AccountView identity: the entrypoint deserializer makes every duplicate slot a copy of the original view, so each check is a single pointer comparison. Explicit trailing-account capture via #[pina(remaining)] preserves account order and rejects duplicate mutable addresses by default; mutable trailing slices also reject readonly accounts. When duplicate addresses are an intentional part of the instruction contract, #[pina(remaining, distinct = false)] restores pass-through aliasing and the field must have a doc comment explaining the invariant that makes it safe. A missing trailing Option<&AccountView> or Option<&mut AccountView> parses as None, which lets a current process accept the shorter positional prefix sent by an older client. An optional field before another positional field still occupies a slot.
Optional accounts
Account fields wrapped in Option mark a slot as optional. Trailing optional fields may be omitted entirely; optional fields before another positional field keep their slots:
#![allow(unused)]
fn main() {
#[derive(Accounts)]
pub struct MakeAccounts<'a> {
pub maker: &'a mut AccountView,
pub escrow: Option<&'a mut AccountView>,
pub witness: Option<&'a AccountView>,
}
}
Only Option<&'a AccountView> and Option<&'a mut AccountView> are supported; other inner types fail to compile.
Within a positional list, the absent convention is the executing program’s own address. Generated Codama clients may fill an omitted optional slot with a readonly account meta pointing at the program address, and on-chain parsing maps it back to None. A trailing optional suffix may instead be left out, including by an older client that predates those fields. Because the filler is readonly, provided values still enforce their declared writability through next_mut_opt().
Program logic branches on presence with plain pattern matching. Load the account directly when the branch needs its fields:
#![allow(unused)]
fn main() {
if let Some(escrow) = self.escrow {
let escrow = escrow.as_account::<EscrowState>(&ID)?;
// Read validated fields through `escrow`.
}
}
Optional signers are validated only when present (if let Some(witness) = self.witness { witness.assert_signer()?; }). In generated clients an optional signer input is a TransactionSigner, so providing one attaches the signature automatically.
Instruction authoring tips
- Entry points should accept
&mut [AccountView]and dispatch withAccounts::try_from((program_id, accounts))?.process(data). - Use
&AccountViewfor read-only accounts and&mut AccountViewonly when you need mutable loaders, direct lamport mutation,close_*helpers, or writable IDL inference. &mut AccountViewdeclares and enforces a writable slot. Useassert_writable()or#[pina(validate(writable))]only when a shared&AccountViewmust arrive writable.as_account()/as_account_mut()returnRef<T>/RefMut<T>borrow guards. Copy out the fields you need anddrop(...)the guard before CPIs or later mutable borrows.- Prefer generated
load_pda*methods for stored-bump fixed PDAs, thenas_account*for other fixed accounts. Useassert_typeonly when no typed fields are needed; do not call it before a typed loader. - Keep validation chains direct inside
process(self, ...)when possible. That makes audits easier and givespina idlthe clearest signal for signer, writable, PDA, and default-account inference.
Entrypoint model
nostd_entrypoint! wires BPF entrypoint plumbing while preserving no_std constraints for on-chain builds.
A program routed by #[discriminator(entrypoint)] can use dispatch_entrypoint!(Instruction) instead. It reads the instruction before any account, then walks only the accounts the routed struct reads, which makes most programs smaller and cheaper to run. See Program size for when each entrypoint measures smaller.
Pod types
| Type | Wraps | Size |
|---|---|---|
PodBool | bool | 1 byte |
PodU16 | u16 | 2 bytes |
PodI16 | i16 | 2 bytes |
PodU32 | u32 | 4 bytes |
PodI32 | i32 | 4 bytes |
PodU64 | u64 | 8 bytes |
PodI64 | i64 | 8 bytes |
PodU128 | u128 | 16 bytes |
PodI128 | i128 | 16 bytes |
All types are alignment-one byte-backed values that implement PinaPod’s ZcElem and ZcValidate contracts. The floats feature adds PodF32 and PodF64, which store IEEE-754 bit patterns in four and eight bytes; they validate any bit pattern and decode an all-zero field as +0.0.
Arithmetic operators (+, -, *) on Pod integer types use wrapping semantics in release builds for CU efficiency and panic on overflow in debug builds. Use checked_add, checked_sub, checked_mul, checked_div where overflow must be detected in all build profiles.
Each Pod integer type provides ZERO, MIN, and MAX constants.
This means you can write ergonomic code like:
#![allow(unused)]
fn main() {
my_account.count += 1u64;
let fee = balance.checked_mul(3u64).unwrap_or(PodU64::MAX);
}
Instruction introspection
The pina::introspection module provides helpers for reading the Instructions sysvar at runtime. This enables:
- Program checks: verify that the transaction-level instruction at the current index targets the expected program (
assert_current_instruction_program_id). The Instructions sysvar cannot distinguish self-CPI, so this is not a no-CPI or flash-loan guard. - Transaction inspection: count instructions (
get_instruction_count) or find the current index (get_current_instruction_index) - Sandwich detection: check whether a specific program appears before or after the current instruction (
has_instruction_before,has_instruction_after)