Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

PinaPod

PinaPod maps validated Solana account and instruction bytes to alignment-one Rust representations. Fixed accounts reserve every bounded field at its maximum size. Compact accounts store only active tail data and can change allocation size.

Version 0.2 is a breaking API release with a stable wire format. It renames the derive and traits to PinaPod, adds bounded containers to fixed accounts, and replaces direct compact-header mutation with checked updates.

Start with Choose an account layout. If you already use PinaPod v0.1, follow Migrate from v0.1 to v0.2; v0.2 users follow Migrate from v0.2 to v0.3.

What v0.2 guarantees

All representations have alignment one, so a stored field can be read at any byte offset without a copy or a relocation.

Safe readers validate tags, lengths, UTF-8, enum discriminants, nested values, and slice bounds before they return a reference.

Safe APIs provide these guarantees:

  • A reader validates the complete representation before it returns a typed reference.
  • Fixed exact reads reject both short buffers and trailing bytes.
  • Fixed prefix reads validate one value at the start of a larger allocation.
  • Compact validation uses checked arithmetic for every length, offset, and byte count.
  • Compact updates validate the complete change before they alter account bytes.
  • Shortening a string, vector, or compact value zeros the removed bytes.
  • Constructors initialize inactive capacity before a representation can be copied into account data.

The byte layout remains compatible with v0.1 and the pinned upstream ZeroPod baseline. Golden tests compare the representations. The benchmark chapter explains how the repository measures the runtime cost of the stronger contracts.

Published documentation

The project publishes this book to GitHub Pages after main and release builds pass. The same workflow builds the Rust API documentation with warnings denied.

To verify both outputs locally, run:

devenv shell verify:docs

Use the pinapod API reference for item signatures. Use this book for layout choices, migration steps, and safety reasoning.

Choose an account layout

Fixed and compact accounts use the same field representations. They differ in where bounded capacity lives.

Fixed accounts reserve capacity in the account

A fixed account always occupies Schema::SIZE bytes.

A container reserves its full capacity wherever it appears, so a smaller value never shrinks the representation.

String<32> occupies its one-byte prefix plus all 32 payload bytes, and Vec<u64, 8> occupies its two-byte prefix plus space for all eight elements.

Option<T> also reserves its tag and the complete representation of T, even when the value is absent.

This layout is the better default when the maximum allocation is small or the program does not need reallocations. A field offset never moves, so each access is direct after validation.

Fixed accounts support recursively bounded combinations. For example, Vec<String<16>, 4>, Option<Vec<u64, 8>>, and Vec<Option<String<8>>, 4> all have one compile-time size.

Read Fixed accounts for construction and read semantics.

Compact accounts reserve capacity in the schema

A compact account has a fixed header followed by active tail bytes. The schema still declares a maximum for each string and vector, but the allocation does not reserve every maximum. A five-byte String<64> tail occupies five payload bytes. Its length metadata lives in the header.

Compact fields save rent when active data is much smaller than its bound. The cost is a resize lifecycle and moving later tails when an earlier tail changes size. Accessors may also need to calculate the positions of preceding tails.

Read Compact accounts for the accepted field grammar and the update lifecycle.

The layouts keep the same field bytes

The container encodings do not change between layouts:

  • Integers use little-endian bytes.
  • Booleans use one byte with values 0 and 1.
  • Strings store a length prefix and UTF-8 bytes.
  • Vectors store an element count and fixed-size element bytes.
  • Options store a zero or one tag and an optional payload.

The compact layout moves string and vector payloads out of their header fields. It does not introduce another value encoding.

Fixed accounts

Use a fixed account when every field has a compile-time bound and the account does not need to follow its active payload size.

Declare bounded fields

Do not add #[pinapod(compact)].

#![allow(unused)]
fn main() {
use pinapod::PinaPod;
use pinapod::String;
use pinapod::Vec;

#[derive(PinaPod)]
struct Profile {
	authority: [u8; 32],
	status: Status,
	display_name: String<32>,
	roles: Vec<u16, 8>,
	note: Option<String<64>>,
}

#[derive(PinaPod, Debug, PartialEq)]
#[repr(u8)]
enum Status {
	Active = 1,
	Suspended = 2,
}
}

The derive creates ProfileZc and exposes Profile::SIZE. The generated size includes all bounded capacity:

32                         authority
+ 1                        status
+ (1 + 32)                 display_name
+ (2 + 8 * 2)              roles
+ (1 + 1 + 64)             note

Option<String<64>> contains both an option tag and the string’s length prefix.

Initialize once, then validate

Use initialize for new bytes. The method zeros the complete destination before it calls your closure. It validates the finished representation once.

#![allow(unused)]
fn main() {
use pinapod::{PinaPodError, String};

let mut data = vec![0xff_u8; Profile::SIZE];
Profile::initialize(&mut data, |profile| {
	profile.authority = [7; 32];
	profile.status = Status::Active.into();
	profile.display_name.try_set("ifi")?;
	profile.roles.try_set([3_u16, 5, 8])?;

	let note = String::<64>::try_from("primary profile")?;
	profile.note.set(Some(note));
	Ok::<(), PinaPodError>(())
})?;
Ok::<(), PinaPodError>(())
}

If the closure fails or the finished value is invalid, initialize zeros the destination again and returns the error. This behavior matters for enums such as Status, where zero is not a valid discriminant. A reader cannot configure such a field because the reader validates before returning.

Choose exact or prefix reads

Use read_exact when the slice must contain one fixed value and nothing else.

#![allow(unused)]
fn main() {
let profile = Profile::read_exact(&data)?;
assert_eq!(profile.display_name.as_str(), "ifi");
assert_eq!(profile.roles[1].get(), 5);
Ok::<(), pinapod::PinaPodError>(())
}

read_exact rejects a slice with trailing bytes. This is the right contract for instruction data and an account allocation whose size is part of the schema.

Use read_prefix when one fixed value starts a larger containing format:

#![allow(unused)]
fn main() {
let mut envelope = vec![0_u8; Profile::SIZE + 16];
Profile::initialize(&mut envelope[..Profile::SIZE], |profile| {
	profile.status = Status::Active.into();
	Ok(())
})?;
let profile = Profile::read_prefix_mut(&mut envelope)?;
profile.display_name.try_set("updated")?;
Ok::<(), pinapod::PinaPodError>(())
}

The prefix methods require at least Profile::SIZE bytes and ignore the remaining bytes. Pick one contract at the boundary. Do not use a prefix read to hide an unexpected account-size mismatch.

Nest bounded containers

Fixed accounts can nest containers when every level has a fixed representation.

#![allow(unused)]
fn main() {
use pinapod::PinaPod;
use pinapod::String;
use pinapod::Vec;

#[derive(PinaPod)]
struct Directory {
	names: Vec<String<16>, 8>,
	aliases: Vec<Option<String<8>>, 4>,
	preferred_ids: Option<Vec<u64, 16>>,
}
}

Each active nested value has its own logical length. The account still reserves each container’s maximum representation. Validation follows every active value and ignores inactive option payloads.

Use a compact account if these maximum representations would waste material rent.

Compact accounts

Use a compact account when its allocation must follow active string and vector data. The schema still sets hard capacities. The account stores a fixed header and only the active tail bytes.

Declare the schema

Add #[pinapod(compact)]. Put fixed fields before dynamic tails.

#![allow(unused)]
fn main() {
use pinapod::PinaPod;
use pinapod::String;
use pinapod::Vec;

#[derive(PinaPod)]
#[pinapod(compact)]
struct Journal {
	authority: [u8; 32],
	revision: u64,
	checkpoint: Option<u64>,
	entries: Vec<u64, 1024>,
	note: Option<String<128>>,
	labels: Vec<String<16>, 32>,
}
}

Journal has three independent dynamic tails. Changing entries moves note and labels as needed. It does not reserve 1,024 entries, 128 note bytes, or 32 labels in every account.

The derive accepts these compact field forms:

  • String<N>
  • Vec<T, N> when T has a fixed representation
  • Option<T> when T has a fixed representation
  • Option<String<N>>
  • Option<Vec<T, N>> when T has a fixed representation
  • Vec<String<M>, N>

PodString<N, PFX> and PodVec<T, N, PFX> are the explicit-prefix versions of the string and vector forms. PFX must be the const value 1, 2, 4, or 8.

Other dynamic nesting is not part of the v0.2 compact format. For example, Option<Vec<String<M>, N>>, Vec<Vec<T, M>, N>, and a compact schema used as a vector element are rejected. The derive error lists the accepted forms and points at the unsupported field.

Why dynamic nesting stops here

A compact tail has no reserved slot for its maximum payload. With a Vec<Vec<T, M>, N>, the address of element five depends on the active lengths of elements zero through four. Direct indexing then requires either a scan or an additional offset table in the wire format.

Updates have the same problem in reverse. Changing one nested value can move every later nested value and every later account tail. The implementation must validate all old and new ranges before the first move. Rust, TypeScript, and Dart also need to agree on the exact offset-table or scan rules.

Version 0.2 supports the combinations that have one bounded calculation per tail. Vec<String<M>, N> fits because each active string occupies one fixed PodString<M> slot. A later release can add recursive packing with a separate wire-format decision and cross-language fixtures.

Understand Vec<String<M>, N>

The outer vector packs only active elements into the tail. Each active string is a fixed PodString<M> representation, so it occupies 1 + M bytes with the default prefix. Individual strings can have different logical lengths.

This is a fixed-footprint element format, not a recursively packed string table. It provides constant-time element indexing and uses the same string validator as a fixed account. See Pod containers and prefixes for the byte cost.

Read active and allocated lengths

read_prefix validates the compact value and returns the generated borrowed reader. The reader distinguishes encoded data from the physical slice:

#![allow(unused)]
fn main() {
let journal = Journal::read_prefix(account_data)?;

assert!(journal.encoded_len() <= journal.storage_len());
assert_eq!(
	journal.spare_capacity(),
	journal.storage_len() - journal.encoded_len(),
);

let revision = journal.revision.get();
let entries = journal.entries();
let note = journal.note();
Ok::<(), pinapod::PinaPodError>(())
}

encoded_len is the header plus active tails. storage_len is the complete supplied allocation. spare_capacity is the difference.

PinaPodCompact enforces the physical allocation contract. The storage length must be between Journal::MIN_SIZE and Journal::MAX_SIZE, inclusive, and growth beyond MIN_SIZE must be a multiple of Journal::TAIL_ALIGNMENT. read_prefix checks this contract before it validates the active value at the start of the allocation. A framework can add account-specific rules, but it cannot bypass the schema bounds or granularity.

Describe one atomic update

The derive generates JournalPatch. A new patch keeps every field unchanged until a builder method sets it.

#![allow(unused)]
fn main() {
let patch = JournalPatch::new()
	.revision(next_revision)
	.checkpoint(Some(13_u64))
	.replace_entries(&entries)
	.note(Some("Updated"));
}

Fixed fields use their field name. An inline Option<T> accepts the native option, so .checkpoint(Some(13_u64)) sets a value and .checkpoint(None) clears it. The same builder accepts PodOption<T::Pod> when a custom fixed type needs its stored representation supplied directly. A vector tail uses replace_ because the slice replaces the complete active vector. An optional string accepts Option<&str>. None sets the field to absent, while omitting the builder call keeps the current value.

A vector replacement borrows the mapped element representation. For Vec<u64, N>, entries is a slice of PodU64. Convert native values with PodU64::from or .into() before building the patch. This contract avoids a hidden allocation in a no_std program.

The patch borrows strings and element slices. It does not expose header prefixes, tail offsets, raw pointers, or staged edit state.

Calculate a resize before changing bytes

Call updated_len while the old account data is borrowed. The method validates the current value and every patch input. It returns the required encoded length without changing account_data.

#![allow(unused)]
fn main() {
let required_len = Journal::updated_len(account_data, &patch)?;
}

Release the account-data borrow before reallocating. Grow the account when required_len exceeds the current allocation. After the resize, borrow the data again and apply the same patch:

#![allow(unused)]
fn main() {
let encoded_len = Journal::update(resized_account_data, &patch)?;
assert_eq!(encoded_len, required_len);
}

update repeats the preflight against the current slice, then applies the complete patch. If preflight fails, the destination is unchanged. If the new value is shorter, update zeros the old encoded suffix. After the mutable borrow ends, the account framework can shrink the allocation to encoded_len.

This two-phase API exists because Solana cannot reallocate account data while a borrow of that data remains active.

Initialize new storage

Use the generated initialize method for a new compact allocation:

#![allow(unused)]
fn main() {
let labels = [
	String::<16>::try_from("todo")?,
	String::<16>::try_from("complete")?,
];

let patch = JournalPatch::new()
	.authority(authority)
	.revision(0)
	.replace_entries(&[])
	.note(None)
	.replace_labels(&labels);

let encoded_len = Journal::initialize(account_data, &patch)?;
}

initialize zeros the supplied storage before applying the patch. It validates the final value once. If an input or final validation fails, the complete supplied slice remains zeroed.

Set every field whose all-zero representation is not valid. A nonzero enum discriminant is the common example.

Let Pina manage the resize lifecycle

Pina wraps updated_len, reallocation, and update in one builder:

#![allow(unused)]
fn main() {
UpdateResizableAccount {
	account: self.journal,
	rent_account: self.authority,
	program_id: &ID,
	patch: JournalPatch::new()
		.revision(next_revision)
		.replace_entries(&entries)
		.note(Some("Updated")),
}
.invoke::<Journal>()?;
}

Use rent_account, matching Pina’s other reallocation builders. The builder drops each account-data guard before it reallocates and parses the resized bytes again before applying the patch.

Pod containers and prefixes

String, Vec, and Option in a schema are bounded account types. They do not use heap allocation. The derive maps them to PodString, PodVec, and PodOption representations.

Schema aliases

The schema aliases choose common prefix widths, so ordinary declarations stay short:

  • String<N> is PodString<N, 1>.
  • Vec<T, N> is PodVec<T, N, 2>.
#![allow(unused)]
fn main() {
use pinapod::String;
use pinapod::Vec;

type Name = String<32>; // PodString<32, 1>
type Scores = Vec<u64, 16>; // PodVec<u64, 16, 2>
}

The aliases are ordinary Rust aliases. An editor can resolve String and Vec to the pinapod imports. The derive does not rewrite the spelling as a hidden macro convention.

Use PodString or PodVec when a format needs an explicit prefix:

#![allow(unused)]
fn main() {
use pinapod::PodString;
use pinapod::PodVec;

type LongText = PodString<1024, 2>;
type SmallList = PodVec<u64, 12, 1>;
type LargeList = PodVec<u64, 100_000, 4>;
}

The last const argument is the prefix byte count.

PFX is the width in bytes of the length prefix or tag that precedes the payload, and it must be 1, 2, 4, or 8.

The capacity must fit that prefix: String<255> is valid, String<256> is not, and PodString<256, 2> restores it.

Do not write a prefix type such as u16, and do not attach a prefix attribute to the field.

Changing a prefix width changes the wire format. Capacity alone does not change existing value bytes, but it changes the fixed representation size.

Choose the capacity from the largest value the schema must hold, because a write that does not fit is rejected rather than truncated.

PodVec maps native elements

PodVec<u64, 8, 2> stores PodU64 elements. Use native element types in a schema and at write boundaries:

#![allow(unused)]
fn main() {
use pinapod::PodVec;

let mut values = PodVec::<u64, 8, 2>::default();
values.try_set([13_u64, 21, 34])?;

assert_eq!(values[0].get(), 13);
Ok::<(), pinapod::PinaPodError>(())
}

try_set and try_extend accept arrays, slices, and standard vectors through AsRef<[T]>. They check the complete input length before changing the destination, then convert each native value into its stored representation. The stored representation remains visible when you read. Integer pod types use methods such as get and set because their bytes can be unaligned.

Removed values become zero bytes

Every container starts with fully initialized backing storage.

Operations that shorten or clear active data zero the bytes they vacate, so a later raw read or canonical serialization cannot disclose a previous value.

Default zeros both the prefix and the inactive capacity, and setting a PodOption to None zeros its payload.

A vector of strings has fixed-size elements

Vec<String<M>, N> is a supported compact tail. The outer vector stores only its active elements, but each active element occupies the complete fixed representation of String<M>.

For Vec<String<8>, 4>, each active element uses nine bytes: one inner length byte and eight payload bytes. The strings can have different logical lengths. The representation still uses nine bytes for "a" and nine bytes for "pinapod".

This fixed footprint keeps element indexing constant-time and reuses PodString validation. It also means that a vector of short strings may use more account bytes than a recursively packed string table. Version 0.2 chooses the fixed-footprint format so the first compact nesting release has one clear layout.

Traits, errors, and features

Most schema code imports only PinaPod. The lower-level traits exist for generic framework code and audited representation extensions.

Feature flags

FeatureAdds
fixedMappings for signed and unsigned fixed 1.30.0 values
floatsPodF32/PodF64 and mappings for native f32/f64
solana-addressA mapping for solana_address::Address
solana-program-errorConversion from PinaPodError to ProgramError
wincodeCanonical SchemaRead and SchemaWrite implementations

No feature is enabled by default, so the core crate stays no_std and dependency-free.

Enable only what a program reads from or writes to the wire.

Traits

TraitContract
PinaPodMarks a schema generated by the derive
PinaPodFixedProvides fixed-size read, validation, and initialization operations
PinaPodCompactDefines compact storage bounds, allocation granularity, and validation
ZcValidateChecks whether initialized bytes contain a semantic value
ZcElemStates that a stored type is alignment one and safe for packed access
ZcFieldMaps a native schema field to its stored pod representation

PinaPodFixed, PinaPodCompact, ZcElem, and ZcField are unsafe to implement. Use the derive unless you need an audited representation that the crate does not provide.

Errors

Safe operations return PinaPodError:

VariantMeaning
BufferTooSmallThe supplied slice cannot contain the required header, value, or active tail
OverflowA requested write exceeds a field capacity or checked arithmetic fails
InvalidBoolA stored boolean byte is not zero or one
InvalidTagA stored option tag is not zero or one
InvalidDiscriminantA stored enum value has no declared variant
InvalidLengthA stored length exceeds capacity or violates the read contract
InvalidUtf8Active string bytes are not UTF-8

The solana-program-error feature maps a small buffer to ProgramError::AccountDataTooSmall.

It maps every invalid representation and every write overflow to ProgramError::InvalidAccountData.

Pod types

Every schema field maps to one of these alignment-one representations. PFX is a prefix width in bytes.

TypeStored sizeMeaning
PodU16 through PodU1282 through 16 bytesUnsigned, little-endian integer
PodI16 through PodI1282 through 16 bytesSigned, little-endian integer
PodBool1 byteBoolean with a 0 or 1 byte
PodF324 bytesIEEE-754 binary32 stored as its bit pattern
PodF648 bytesIEEE-754 binary64 stored as its bit pattern
PodOption<T, PFX>PFX + size_of::<T>()Optional fixed representation
PodString<N, PFX>PFX + NUTF-8 string with at most N bytes
PodVec<T, N, PFX>PFX + N * mapped element sizeVector with at most N mapped pod elements

A schema field declared as a native type maps to its pod through ZcField, so the derive accepts spellings such as u64, bool, f32, and [u8; 32] directly. Pod types that appear in a schema without a mapping are stored as-is.

Numeric pods

Numeric pod types store little-endian bytes and have alignment one. Use get and set to cross the unaligned representation boundary.

#![allow(unused)]
fn main() {
use pinapod::pod::PodU64;

let mut amount = PodU64::from(100_u64);
amount.set(125);
assert_eq!(amount.get(), 125);
assert_eq!(amount.checked_sub(25), Some(PodU64::from(100)));
}

Numeric pods do not implement arithmetic, remainder, bitwise, shift, assignment, or negation operators. Choose the overflow contract at the call site with the retained checked_*, wrapping_*, and saturating_* methods. Signed pods also provide checked_neg and wrapping_neg. For another operation, decode with get, apply the native operation, and convert the result back.

Pod-to-pod comparisons and pod-left comparisons with native integers remain available. Native-left reverse PartialEq and PartialOrd implementations do not.

How the derive recognizes container types

The derive classifies a field as a dynamic string, vector, or option by its spelling: an unqualified String, Vec, or Option, a path through the resolved pinapod dependency (including a pinapod::pod:: prefix), or a pina:: re-export. Every other path is treated as a fixed inline type that must provide its own ZcField mapping.

This has one consequence worth knowing: a module of yours that is literally named pinapod (or pina) containing its own String/Vec/Option types is classified as PinaPod’s dynamic containers, because the derive matches the path segments rather than the resolved crate. The result stays memory-safe — the field is still validated before reference formation — but its wire meaning changes silently. Avoid module names that shadow the dependency, or spell such fields through an unambiguous path. Type names alone never grant a built-in representation: a caller-local struct i8(bool) lookalike still fails to compile as a schema field.

Errors and prefix widths

PinaPodError implements core::error::Error and is non_exhaustive: match with a wildcard arm so new validation variants can arrive in minor releases. All three container families accept prefix widths of 1, 2, 4, or 8 bytes; PodOption reports its raw decoded tag as u64 so an eight-byte tag can never truncate into a valid value.

Compact storage bounds

PinaPodCompact defines MIN_SIZE, MAX_SIZE, and TAIL_ALIGNMENT. validate_storage_len rejects an allocation outside the inclusive size bounds or whose growth beyond MIN_SIZE is not a multiple of TAIL_ALIGNMENT. Generated compact readers perform this check before they validate active fields and tails.

A direct compact derive exposes the same values as inherent Type::MIN_SIZE, Type::MAX_SIZE, and Type::TAIL_ALIGNMENT constants. Framework code can use the trait constants when it suppresses generated inherent helpers.

Fixed-point values

Enable fixed to map every signed and unsigned fixed width to its integer pod. PinaPod pins fixed 1.30.0 because later versions require a newer compiler than PinaPod’s Rust 1.89 baseline.

[dependencies]
fixed = { version = "=1.30.0", default-features = false }
pinapod = { version = "0.2", features = ["fixed"] }
#![allow(unused)]
fn main() {
use fixed::types::I16F16;
use pinapod::PinaPod;

#[derive(PinaPod)]
struct Price {
	mark: I16F16,
}

let mut data = [0_u8; Price::SIZE];
Price::initialize(&mut data, |price| {
	price.mark = I16F16::from_num(10.25).to_bits().into();
	Ok(())
})?;

let price = Price::read_exact(&data)?;
let mark = I16F16::from_bits(price.mark.get());
assert_eq!(mark, I16F16::from_num(10.25));
Ok::<(), pinapod::PinaPodError>(())
}

Fixed-point storage contains only the raw little-endian bits. The scale comes from the Rust field type, not from extra wire metadata.

IEEE-754 float values

Enable floats to add PodF32 and PodF64 and map the native f32 and f64 primitives to them. A schema field declared as f32 or f64 is accepted directly, and the generated accessor decodes back to the native float.

[dependencies]
pinapod = { version = "0.3", features = ["floats"] }
#![allow(unused)]
fn main() {
use pinapod::PinaPod;

#[derive(PinaPod)]
struct Reading {
	temperature: f32,
	depth: f64,
}

let mut data = [0_u8; Reading::SIZE];
Reading::initialize(&mut data, |reading| {
	reading.temperature.set(-12.5);
	reading.depth.set(3.125);
	Ok(())
})?;

let reading = Reading::read_exact(&data)?;
assert_eq!(reading.temperature(), -12.5);
assert_eq!(reading.depth(), 3.125);
Ok::<(), pinapod::PinaPodError>(())
}

get and set convert to and from the native float; to_bits and set_bits expose the raw pattern.

Storage is the complete IEEE-754 bit pattern, little-endian.

Every bit pattern is a valid stored value, so validation never rejects a NaN, an infinity, or the sign of zero, and an all-zero field decodes as +0.0.

Equality compares stored bit patterns rather than decoded floats.

That keeps Eq sound in the presence of NaN payloads and preserves the distinction between +0.0 and -0.0. The pods deliberately implement no PartialOrd or Ord, because bitwise equality and float ordering cannot both hold: an ordering would have to rank NaN payloads and separate +0.0 from -0.0. Decode with get and compare the natives when an ordering is needed.

PodF32 and PodF64 are byte containers, not arithmetic types: they provide no operators, so multi-step float math belongs at the call site on the decoded natives.

For fractional values that carry economic meaning, prefer fixed-point storage. Fixed-point arithmetic is exact integer math, while native float operations round at every step and have no hardware accelerated unit on the SBF target.

Other optional integrations

The solana-address feature maps solana_address::Address to its alignment-one 32-byte representation.

The wincode feature implements canonical SchemaRead and SchemaWrite for pod types. Nested fixed containers compose. Writers zero inactive capacity, and the types do not claim direct-borrow ZeroCopy support.

Migrate from v0.1 to v0.2

Version 0.2 changes the Rust API but preserves v0.1 account and instruction bytes. Migrate source code and generated clients together. You do not need an on-chain data migration unless you also change a field type, capacity, prefix width, order, or enum discriminant.

Update the dependency

[dependencies]
pinapod = "0.2"

Keep any existing feature flags. The PinaPod runtime remains no_std.

Rename the derive and public contracts

Replace the old public names as follows:

v0.1 namev0.2 name
ZeroPodPinaPod
ZeroPodSchemaPinaPod
ZeroPodFixedPinaPodFixed
ZeroPodCompactPinaPodCompact
ZeroPodErrorPinaPodError

Before:

#![allow(unused)]
fn main() {
use pinapod::{ZeroPod, ZeroPodFixed};

#[derive(ZeroPod)]
struct Counter {
	value: u64,
}

let counter = Counter::from_bytes(data)?;
Ok::<(), pinapod::ZeroPodError>(())
}

After:

#![allow(unused)]
fn main() {
use pinapod::PinaPod;

#[derive(PinaPod)]
struct Counter {
	value: u64,
}

let counter = Counter::read_exact(data)?;
Ok::<(), pinapod::PinaPodError>(())
}

The derive adds the common PinaPod contract and either PinaPodFixed or PinaPodCompact. Most applications only need to import the derive.

Configure framework re-exports

The derive automatically resolves a renamed direct pinapod dependency. No attribute is required for this Cargo dependency:

[dependencies]
account-pod = { package = "pinapod", version = "0.2" }

A framework that re-exports PinaPod must tell the derive where its runtime re-export lives. It can also suppress inherent fixed helpers when the framework provides account-aware methods with the same names:

#![allow(unused)]
fn main() {
#[derive(pina::PinaPod)]
#[pinapod(crate = pina::pinapod, no_inherent)]
struct FrameworkAccount {
	value: u64,
}
}

no_inherent removes only the schema’s inherent fixed helper methods. The PinaPod and PinaPodFixed implementations remain available. Direct derives should keep the default helpers.

Choose fixed read semantics

The v0.1 fixed reader accepted a valid value at the start of a longer slice. Version 0.2 separates that behavior from exact reads.

Use these replacements:

v0.1 callv0.2 call when trailing bytes are invalidv0.2 call for a containing format
from_bytes(data)read_exact(data)read_prefix(data)
from_bytes_mut(data)read_exact_mut(data)read_prefix_mut(data)
validate(data)validate_exact(data)validate_prefix(data)

Prefer exact reads for account types whose allocation size is part of their contract and for instruction data. Use a prefix read only when another format owns the remaining bytes.

Use fixed initialization for new storage

Do not read a zeroed destination before configuring it. A schema can contain an enum whose valid discriminants do not include zero.

Before:

#![allow(unused)]
fn main() {
let value = Counter::from_bytes_mut(&mut data)?;
value.value = 1_u64.into();
}

After:

#![allow(unused)]
fn main() {
Counter::initialize(&mut data, |value| {
	value.value = 1_u64.into();
	Ok(())
})?;
Ok::<(), pinapod::PinaPodError>(())
}

initialize requires an exact-size slice. It zeros the destination, runs the closure, and validates the finished representation. If the closure or validation fails, the method zeros the destination again.

Add bounded containers to fixed accounts

PinaPod v0.2 supports String<N>, Vec<T, N>, and Option<T> in fixed accounts. It also supports recursively bounded combinations.

#![allow(unused)]
fn main() {
use pinapod::PinaPod;
use pinapod::String;
use pinapod::Vec;

#[derive(PinaPod)]
struct Profile {
	display_name: String<32>,
	tags: Vec<u16, 16>,
	bio: Option<String<128>>,
	previous_names: Vec<String<32>, 4>,
}
}

These fields reserve their complete capacities in Profile::SIZE. Use a compact account if the active values must determine rent.

Replace numeric operators

Version 0.2 removes the following operator implementations from integer pod types:

Removed surfaceIncludes
ArithmeticAdd, Sub, Mul, Div, and Rem between pods, between a pod and a native integer, and with the native integer on the left
Arithmetic assignmentAddAssign, SubAssign, MulAssign, DivAssign, and RemAssign, with pod or native right-hand values
BitwiseBitAnd, BitOr, BitXor, and Not
Bitwise assignmentBitAndAssign, BitOrAssign, and BitXorAssign
ShiftsShl and Shr
Shift assignmentShlAssign and ShrAssign
Signed negationNeg
Native-left comparisonsnative == pod, native < pod, and the other reverse PartialEq and PartialOrd forms

The explicit numeric API makes overflow behavior visible at every call site.

Before:

#![allow(unused)]
fn main() {
let next = current + 1_u64;
}

After, when overflow is an error:

#![allow(unused)]
fn main() {
let next = current
	.checked_add(1_u64)
	.ok_or(pinapod::PinaPodError::Overflow)?;
}

The retained methods are:

  • checked_add, checked_sub, checked_mul, and checked_div
  • wrapping_add, wrapping_sub, and wrapping_mul
  • saturating_add, saturating_sub, and saturating_mul
  • checked_neg and wrapping_neg on signed pods

There is no pod method for remainder, bitwise operations, or shifts. Decode with get, apply the native operation after checking its preconditions, then convert the result back:

#![allow(unused)]
fn main() {
let mask = PodU64::from(flags.get() & 0xff);
let remainder = amount
	.get()
	.checked_rem(divisor.get())
	.map(PodU64::from)
	.ok_or(pinapod::PinaPodError::Overflow)?;
}

Replace a compound assignment by calculating the result and assigning it, or by calling set with a native result. Pod-left comparisons remain available. Reverse native-left comparisons by swapping the operands and relation: replace native == pod with pod == native, and replace native < pod with pod > native.

Replace iterator-based vector writes

PodVec::try_set and PodVec::try_extend now accept a slice-like input through AsRef<[T]>, including arrays, slices, and standard vectors. They no longer accept an arbitrary ExactSizeIterator. Knowing the complete input length before the first write makes overflow failures atomic and avoids trusting a user-defined iterator’s reported length.

Collect a transformed iterator at the application edge, or fill an array, then pass that collection to the pod vector:

#![allow(unused)]
fn main() {
let native = vec![3_u64, 5, 8];
profile.roles.try_set(&native)?;
Ok::<(), pinapod::PinaPodError>(())
}

Put prefix width in the field type

Remove any proposed or local #[pinapod(prefix = ...)] syntax. Use the final const generic on the pod type:

#![allow(unused)]
fn main() {
use pinapod::PodString;
use pinapod::PodVec;

type Memo = PodString<1024, 2>;
type Entries = PodVec<u64, 1024, 2>;
}

Write the prefix width as 1, 2, 4, or 8, not as u8, u16, u32, or u64. Changing the width changes the wire format.

Replace direct compact mutation

Version 0.1 generated a mutable view, exposed inline header fields through mutable dereferencing, staged tail pointers with set_*, and changed bytes in commit. Version 0.2 uses a typed patch. The patch keeps dynamic metadata private and validates every requested value before writing.

Inline semantic options accept ordinary Rust values: .checkpoint(Some(13_u64)) sets the value and .checkpoint(None) clears it. When a custom fixed type does not convert directly to its stored representation, pass PodOption<T::Pod> to the same builder.

For a resize, use the same patch for both phases:

  1. Read the current value and calculate the updated encoded length.
  2. Release the account-data borrow.
  3. Grow the account if the calculated length exceeds the allocation.
  4. Borrow account data again and apply the patch.
  5. Release the borrow, then shrink the allocation when needed.

The compact account guide shows the generated patch API and a complete grow-update-shrink flow.

Update manual unsafe implementations

PinaPodFixed and PinaPodCompact are unsafe traits in v0.2. A manual implementation must uphold the layout and validation contract documented on the trait.

Remove a manual PinaPodFixed::SIZE. The trait no longer has a size constant. A direct derive exposes inherent Type::SIZE, calculated from size_of::<TypeZc>(). Generic code can calculate size_of::<T::Zc>() when T: PinaPodFixed.

Remove ZcField::POD_SIZE for the same reason. These changes prevent separate size metadata from disagreeing with the type that an unsafe operation reads.

Most projects should replace manual implementations with #[derive(PinaPod)].

Replace removed unsafe shortcuts

Do not replace the removed safe PodOption::value_unchecked method with an unchecked reference. Use get_ref, which returns None without exposing the inactive payload. Use unsafe { assume_init_ref() } only in code that can prove the tag is Some and that the payload has already passed validation.

Compact length fields and edit descriptors are no longer public. Use generated accessors and patches. Do not reconstruct a compact reader or writer from its fields.

Verify wire compatibility

Run the repository fixtures before deploying a migration:

devenv shell test:all
devenv shell test:miri
devenv shell bench:compare

For Pina, regenerate every Rust, TypeScript, and Dart client after changing the derive name. Compare the generated bytes with the committed cross-language fixtures before updating a program.

Migrate from v0.2 to v0.3

Version 0.3 preserves v0.2 account and instruction bytes exactly. Wire compatibility needs no on-chain data migration. The source changes are small, but callers must also review zero-capacity emptiness behavior and generated compact Ref size assertions.

Update the dependency

[dependencies]
pinapod = "0.3"

The runtime crate now pins pinapod-derive to its exact released version, so a pinapod upgrade always carries its matching derive. Projects that vendor or fork the crates should keep the two versions in lockstep.

Add a wildcard arm to PinaPodError matches

PinaPodError is now #[non_exhaustive]. An exhaustive match no longer compiles:

#![allow(unused)]
fn main() {
match error {
    PinaPodError::BufferTooSmall => retry(),
    PinaPodError::Overflow => return Err(Overflow),
    PinaPodError::InvalidBool
    | PinaPodError::InvalidTag
    | PinaPodError::InvalidDiscriminant
    | PinaPodError::InvalidLength
    | PinaPodError::InvalidUtf8 => revert(),
}
}

Keep a wildcard arm so later minor releases can add validation variants without breaking your build:

#![allow(unused)]
fn main() {
match error {
    PinaPodError::BufferTooSmall => retry(),
    PinaPodError::Overflow => return Err(Overflow),
    other => revert(other),
}
}

Code that compares with ==, assert_eq!, or matches! needs no change. PinaPodError also implements core::error::Error in v0.3, so it composes with ? and error-reporting crates without an adapter.

Widen option tag inspection to u64

PodOption::raw_tag returned u32; it now returns u64. Only code that inspects raw tags is affected:

#![allow(unused)]
fn main() {
let tag: u64 = option.raw_tag();
assert_eq!(tag, 1);
}

Safe accessors such as get, get_ref, is_some, and tag_valid are unchanged. The wider return type lets PodOption accept eight-byte prefixes without truncating a hostile tag into a valid value.

Revisit zero-capacity is_empty expectations

PodString::is_empty and PodVec::is_empty now report the capacity-clamped length, matching len. The difference is observable only on zero-capacity containers whose length prefix holds a corrupt non-zero value: String<0> and Vec<T, 0> now report is_empty() == true for such bytes. Readers rejected those bytes before and still reject them; only the pre-validation accessor changed.

Compact update preflight and reader shape

The 0.3 line briefly cached one tail offset per dynamic field inside generated compact Ref structs. On-chain measurement showed the cache never reduced compute units on real programs while update instructions paid for it, so from 0.3.1 the reader keeps the 0.2 view shape (accessors walk preceding length prefixes) and the update preflight walks the current tails once with plain locals. Schema code does not change, update instructions get cheaper, and size_of assertions on generated views stay valid across 0.2 and 0.3.1.

What did not change

  • Wire bytes: fixed and compact layouts, prefix widths, option tags, enum discriminants, and wincode encodings are identical to v0.2.
  • The MSRV stays Rust 1.89.
  • All v0.2 reader, patch, and initialization APIs keep their names and signatures, except the items above.
  • PodOption additionally accepts the eight-byte prefix that strings and vectors already supported: PodOption<T, 8> is new, not a replacement.

Verify the migration

devenv shell test:all
devenv shell test:miri

For Pina, no client regeneration is required: the wire format and the generated account layouts are unchanged. Re-run the cross-language fixtures if your fork also carries local schema changes.

Safety model and regressions

PinaPod uses unsafe code to form typed references over account bytes. The public safe API must prove every condition required by those casts. Validation is part of memory safety, not an optional data-quality check.

Safe readers establish the boundary

A safe fixed reader checks the slice length and recursively validates the mapped representation. A safe compact reader first checks the physical allocation against the schema’s minimum, maximum, and tail granularity. It then checks the header, option tags, length prefixes, field capacities, arithmetic, element values, UTF-8, and every source range before it exposes a field.

The reader owns the proof for the lifetime of its borrow. Code cannot construct a generated compact reader from fields or mutate its stored length metadata.

PinaPod separates two facts:

  • Fully initialized bytes can still contain a semantically invalid value.
  • A semantically valid active value does not prove that inactive capacity was initialized.

Constructors and updates address both facts. They initialize all destination bytes, then validate active values before returning success.

Unsafe traits carry representation contracts

ZcElem, ZcField, PinaPodFixed, and PinaPodCompact are unsafe extension points. Their contracts cover alignment, padding, bit validity, size, and validation. Prefer #[derive(PinaPod)] for schema types.

PinaPodFixed has no size constant. Direct derives expose inherent Type::SIZE, calculated from size_of::<TypeZc>(). Generic code derives the size from size_of::<T::Zc>(). ZcField has no separate size constant. An implementation cannot claim that a one-byte object contains two readable bytes.

PinaPodCompact owns the physical storage contract through MIN_SIZE, MAX_SIZE, TAIL_ALIGNMENT, and validate_storage_len. Its validator must enforce that contract before it inspects the representation.

Unchecked readers remain unsafe. Their caller must prove all documented slice and representation requirements. They are not a faster substitute at an account boundary.

Writes either finish or leave canonical bytes

Fixed initialize zeros the destination before configuration. If configuration or validation fails, it zeros the destination again.

A compact patch performs all capacity, arithmetic, and supplied-value checks before it changes account data. When an update shortens the encoded value, PinaPod zeros the old suffix. Container operations also zero removed values and inactive option payloads.

This policy prevents stale payload disclosure and makes repeated writes produce the same bytes.

Every reported unsoundness path has a regression

The v0.2 review converted each confirmed path into a native test, a Miri test, a compile-fail test, or a Kani proof. The table points to the closest permanent check.

Reviewed failurePermanent checkWhat it proves
A default string or vector copied uninitialized capacitycontainer_initialization::assigning_default_containers_keeps_every_account_byte_initialized and compact_copy_initializes_inactive_capacity_in_nested_containersFresh and nested container representations contain initialized bytes
An absent option exposed unchecked payload bytes as a valid Tcontainer_initialization::absent_options_do_not_expose_or_validate_inactive_payloads and the PodOption::value_unchecked compile-fail exampleSafe code cannot borrow an inactive semantic value
A shorter string or vector retained the removed valuecontainer_initialization::shortening_or_clearing_a_vector_zeroes_removed_values and shortening_or_clearing_a_string_zeroes_removed_bytesRemoved fixed-container bytes become zero
Wincode serialized inactive or nested capacity inconsistentlywincode_serialize::serialization_does_not_disclose_truncated_capacity and its nested-container round tripsSerialization is fixed-size, recursive, and canonical
A fixed reader silently accepted trailing datafixed_pina_pod::fixed_exact_reads_reject_trailing_bytesExact and prefix reads have different contracts
Zero was not a valid enum value during initializationThe three fixed_pina_pod::initialization_* testsConfiguration runs before validation, and failures zero the destination
A derive replayed enum discriminant expressions in an invalid contextfixed_pina_pod::enum_uses_compiler_evaluated_discriminants, ui/pass/enum_discriminant_expressions.rs, and ui/pass/compact_enum_discriminant_expressions.rsThe generated code uses compiler-evaluated discriminants
A caller-local type named like a Rust primitive received a built-in mappingui/fail/primitive_name_shadowing.rsA type name alone cannot grant a representation contract
A malformed compact tail escaped its slice through offset or length overflowCompact overflow and capacity regressions in compact_backend.rsChecked arithmetic rejects the bytes before a pointer operation
A compact writer accepted an invalid supplied elementCompact mutation validation regressions in compact_backend.rsPatch preflight rejects invalid values without changing the destination
Compact initialization failed after receiving nonzero destination bytescompact_backend::compact_initialize_zeroes_the_destination_after_an_errorFailed initialization leaves the complete destination zeroed
Safe code forged a generated compact view, patch, or removed mutable viewui/fail/compact_ref_construction_private.rs, compact_header_construction_private.rs, compact_patch_construction_private.rs, compact_patch_metadata_private.rs, and compact_mut_not_exported.rsOnly generated constructors create views, patch metadata stays private, and the mutable view is not exported
A framework-owned field leaked into a generated patchui/pass/compact_skip_patch.rs, ui/fail/compact_skip_patch_builder.rs, and ui/pass/compact_inline_only_patch.rsskip_patch omits its builder while patches remain usable for dynamic and inline-only schemas
A compact shrink retained an old tail suffixCompact shortening regressions in compact_backend.rsBytes from the old encoded suffix become zero
An eight-byte fixed prefix truncated on a 32-bit targetvalidation::validate_rejects_eight_byte_lengths_that_do_not_fit_usize and the i686 CI jobLength conversion fails instead of accepting a truncated prefix
A dishonest exact-size iterator partially changed an existing vectorpod_types::pod_vec_slice_like_setters_are_atomic_on_overflowSlice-like setters know the complete length and leave bytes unchanged on overflow
A prefix width or capacity could not fit its representationThe seven ui/fail/prefix_*.rs and ui/fail/fixed_prefix_*.rs fixturesInvalid prefixes fail during compilation
A compact vector used a zero-sized or dynamically nested elementui/fail/zero_sized_compact_vec.rs and ui/fail/unsupported_compact_nesting.rsUnsupported layouts fail with schema-specific guidance
Generated names collided with a schema lifetime or a renamed dependencyui/pass/compact_lifetime_a.rs and renamed_dependency.rsMacro hygiene does not depend on one lifetime or Cargo dependency spelling
A generated commit trusted its construction-time validation instead of re-establishing itcompact::tests::generated_commits_revalidate_the_buffer_before_relocating_tails (a derive unit test over the emitted tokens)Every generated commit, tail-bearing or tail-free, validates the buffer before its offset walk
Commit-time revalidation rejected a one-based inline enum during initializecompact_backend::compact_initialize_writes_inline_enums_before_commit_validatesA patch writes its inline values before commit validates, so the zeroed-start rule for restricted-domain fields still holds
A patch preflight and its commit could disagree about the new length in release buildscompact::tests::generated_updates_fail_closed_when_preflight_and_commit_lengths_diverge, plus the preflight-equals-commit assertions in the compact_patch and compact_enum_patch fuzz targetsDivergence is a release-mode InvalidLength error rather than a silently wrong length
A Wincode writer copied a corrupt length prefix verbatim into the wire formatwincode_serialize::string_writer_rejects_a_length_prefix_above_its_capacity and vec_writer_rejects_a_length_prefix_above_its_capacitySerialization rejects a prefix that cannot round-trip, matching the option writer’s tag check
A rejected compact update could leave the account unusable for the next patchcompact_commit_shift::compact_patch_retry_after_a_rejected_update_still_commits and compact_backend::compact_wide_tagged_union_patch_rejects_over_capacity_atomicallyA fitting patch still commits over the exact bytes a rejected patch left behind
Editing only the last tail was untested while earlier tails sat near capacitycompact_commit_shift::compact_patch_last_tail_edits_preserve_large_earlier_tailsGrow, shrink, and empty edits of the final tail preserve large earlier tails
Reinitializing an existing valid account was untestedcompact_backend::compact_initialize_over_an_existing_valid_account_rewrites_every_byteA second initialize produces exactly the new value and zeroes the vacated suffix
Zero-capacity containers were untestedpod_types::zero_capacity_containers_accept_only_empty_valuesString<0> and Vec<_, 0> hold only empty values and still validate
A layout-only commit entry could have dropped a bound it must keepcompact::tests::generated_layout_walk_keeps_every_bound_and_drops_every_semantic_check, plus the derive’s validate_layout/validate pair being emitted from one set of per-field fragmentsEvery chained tail bound and tag rejection survives in the layout walk, and no semantic check leaks back in
A hand-written PinaPodCompact impl could have weakened itself through the new methodvalidation::validate_layout_defaults_to_the_full_walk_for_hand_written_implsThe provided validate_layout defaults to the full validate, so an impl that does not override it keeps every semantic check
A corrupt prefix at commit entry could have relocated tails over unchecked bytescompact_backend::compact_commit_fails_closed_on_a_corrupt_prefix_at_entry, validate_layout_rejects_a_prefix_above_its_capacity, and validate_layout_rejects_a_tail_end_past_the_buffercommit still fails closed on a corrupt prefix, a short buffer, and a tail end past the allocation
The documented layout-versus-semantics split could have drifted from the reader’s behaviorcompact_backend::validate_layout_accepts_a_semantically_invalid_but_readable_layout, compact_enum_validate_layout_accepts_a_readable_but_invalid_payload, and validate_layout_matches_the_layout_half_of_validate_on_valid_bytesRelocation accepts a readable layout, and every value-exposing boundary still rejects the invalid content
The commit-entry depth feature could have meant nothing where a consumer does not define itfeature_gate::commit_entry_depth_follows_the_feature, run both with and without compact-commit-full-validationThe depth dispatch resolves against PinaPod’s features, not the consumer’s, and the feature flips the check in both directions

Keep this map current when an API change moves a test. Do not delete a regression because a public method changed. Port the smallest reproducer to the replacement API.

Upstream review log

PinaPod is a fork of ZeroPod that reviews upstream changes before porting them. Every upstream safety release is compared against this codebase, and the comparison is recorded here so the next review starts from a known state.

ZeroPod 0.3.6 (upstream PR #34, reviewed 2026-09-19)

Upstream’s safety release fixed uninitialized storage disclosure, generated-view fabrication, missing commit revalidation, adjacent-vector offset arithmetic, unchecked narrowing, and unvalidated Wincode input. Review outcome: the fork’s v0.2 redesign had already fixed or avoided every item through a different API shape, with one hardening gap that was ported.

Upstream fixPinaPod status at review
MaybeUninit::uninit() inactive capacity disclosureAlready zeroed: constructors zero, shrinking operations zero vacated bytes, initialize zero-fills first
Safe value_unchecked exposing inactive option payloadsNever existed; only unsafe assume_init_ref, with a compile-fail example
decode_len truncating u64 as usize on 32-bit targetstry_decode_len rejects lengths wider than usize; i686 CI job and regressions
try_extend_from_slice overflowchecked_add with a capacity filter
PodVec::remove overlapping copyptr::copy (memmove semantics) since the fork
Wincode zero-copy reads bypassing validationReaders validate through ZcValidate; no ZeroCopy impls; writers emit active bytes plus canonical zero padding
Fabricated views and redirected pending edits (ViewState)Module privacy: view fields are private in a child module and the mutable view is not exported; compile-fail fixtures pin it
Commit-time header revalidationPorted at review. Generated commit now validates its buffer before the offset walk (regression row above)
total_len confused the allocation with the encoded lengthThe fork tracks the encoded length from the prefixes
Checked offset and narrowing arithmeticChecked helpers plus compile-time capacity bounds that license the fast paths for narrow prefixes
Derived prefix-width validation at the derive entryvalidate_dynamic_prefix_args runs on struct fields and compact-enum payloads

Known divergence: upstream restricts PodOption prefixes to at most four bytes. PinaPod accepts an eight-byte option prefix because its encode_tag/decode_tag round-trip the full u64 tag, so no narrowing exists to protect against.

Performance decision log

Performance shapes that were measured and decided stay recorded here so a later review does not re-propose a rejected change with the same justification.

Compact reader offsets stay uncached (measured on-chain, revisited 2026-09-19)

PinaPod 0.3.0 cached every tail’s start offset inside the generated compact Ref at construction, making each accessor O(1) instead of re-walking the preceding length prefixes. On-chain CU measurement across Pina’s example programs showed the cache never reduced any measured read instruction, while update instructions paid for the wider view and its invalidation rules. 07578ba returned the readers to uncached accessors and kept the flat, uncached preflight walk (priced at +3 CU on a write and +75 CU on a resize, a cost the atomic update contract accepts); the wire format and every safety property were unchanged.

The 2026-09-19 audit re-raised the offset cache and the related idea of making validate return the encoded length to fold away the construction walk. Both stay rejected on that measurement: the first was tried and reverted, and the second is a breaking PinaPodCompact change whose benefit is one construction-time walk — the same single-digit CU class the earlier measurement priced a full walk at. The commit-time revalidation added at that same review costs one additional validation walk per commit and is documented in its changeset.

Commit-entry revalidation moved to a layout-only walk (measured on-chain, 2026-09-21)

The commit-entry revalidation added by the 0.4.2 hardening ran the full PinaPodCompact::validate on every commit. That walk is stronger than the pointer arithmetic it guards. Relocation reads exactly three things from the buffer — data.len(), the header size, and the stored length prefixes — then performs checked adds and moves bytes between the offsets they imply. Copying arbitrary bytes is safe, so the only hazard is an offset or end computed past the buffer: a layout precondition, not a semantic one. The full walk additionally visits every tail element and checks UTF-8, enum ranges, and PodBool tags — bytes commit never interprets, and work proportional to the number of elements rather than the number of prefixes.

Pina’s compact_accounts_program benchmark priced the added walk at +308 CU on write, +292 on resize, +157 on rename, and +46 on initialize against 0.4.1, because write and resize walk four tails, rename two, and initialize none. Two changes removed that cost:

  1. Trivial element walks are stated, not looped. ZcValidate::validate_slice joins validate_array as an overridable walk, and impl_zc_validate_trivial! plus the u8, i8, float, and Solana Address impls override it to a no-op. A Vec<u64> or PodVec<u8> tail previously burned CU proving Ok(()) per element, because a loop that is merely dead after inlining is not reliably removed at -C opt-level=3 on SBF.
  2. commit proves layout, not semantics. PinaPodCompact::validate_layout is a provided method defaulting to the full validate, so a hand-written impl cannot weaken itself by accident; the derive overrides it with the prefix decode, capacity, and chained bounds and no element iteration. Both depths are emitted from the same per-field fragments, so a bound cannot be tightened in one walk and left stale in the other. The four semantic boundaries — the Ref and Mut constructors, updated_len, and try_initialize’s post-commit check — still run the full validate, as do the patch-input validations.

Measured on the same benchmark, the two changes together recover the regression and land below 0.4.1 on three of the four instructions (−39 CU on initialize, −110 on resize, −104 on write, +36 on rename). All four are inside the gate’s noise band: the gate warns only at +250 CU and +5%, and rename’s +36 CU is +0.78%.

The residual is the layout walk itself, which is irreducible while commit fails closed on a corrupt prefix. It was measured directly by building the same program with the commit-entry call removed: the check costs +15 CU on initialize, +22 on rename, +30 on resize, and +33 on write. A proof-token scheme that skips it on the generated update path was considered and rejected on that measurement — the residual attributable to the check is single-digit to low-double-digit CU per commit, and the token would add public API surface to generated types for it.

Residual behavior change, recorded because it is a deliberate refinement rather than a loss: a buffer that is layout-valid but semantically invalid (invalid UTF-8 in an untouched tail, for example) is no longer rejected by commit. It is still rejected at the next read, and the generated update path preserves the “an update cannot persist semantically invalid bytes” invariant by construction — updated_len runs the full validate over the same bytes the commit will see, and the staged edits are (ptr, len) intents that have not been written yet, so commit receives byte-identical input to what was just validated. try_initialize keeps its post-commit full validate, so initialization cannot persist such bytes either.

compact-commit-full-validation restores the full walk at the commit entry for consumers with different CU budgets. It lives behind a runtime-side dispatch function rather than a #[cfg] in the generated body, because a cfg in an expansion resolves against the consumer’s feature namespace, where the name is usually undefined — the generated code would silently take the disabled branch and the feature would mean nothing.

Run the checks

Run native and Miri suites from the repository root:

devenv shell test:all
devenv shell test:miri

tests/compile_contracts.rs runs the pass and compile-fail fixtures. Every compile-fail fixture has a checked .stderr diagnostic, so a less useful macro error also fails the suite. tests/renamed_dependency.rs builds its standalone fixture crate. Both compile-only drivers are excluded under Miri and run in the native suite instead.

CI runs Kani with:

cargo kani -p pinapod --features kani

Kani proves integer representation round trips, ordering, explicit checked, wrapping, and saturating arithmetic, prefix semantics, and inactive option handling over symbolic inputs. Miri exercises the real pointer casts and detects invalid references, out-of-bounds operations, and reads of uninitialized memory.

The ordinary native suite also compares v0.2 bytes with pinned PinaPod v0.1 and upstream ZeroPod fixtures in wire_compatibility.rs. Cross-language fixtures remain necessary. Miri can show that a Rust implementation is memory-safe while every generated client agrees on the wrong wire format.

Migrate Pina

Pina and PinaPod can be developed in parallel, but they cannot merge in either order. The Pina change depends on the final PinaPod v0.2 API. Merge and publish PinaPod first, then point Pina at the published release.

Keep both pull requests usable during development

During development, use a local path override in the Pina worktree. This lets Pina compile against every PinaPod API change without publishing an intermediate crate.

Before the Pina pull request merges:

  1. Merge the PinaPod pull request after its native, Miri, Kani, documentation, security, and benchmark checks pass.
  2. Publish pinapod-derive and pinapod v0.2 in dependency order.
  3. Replace the Pina path override with the released pinapod = "0.2" dependency.
  4. Regenerate all Pina clients and documentation.
  5. Run the complete Pina workspace and example suites.
  6. Merge the Pina pull request.

This order keeps both main branches buildable. It also gives Pina’s lockfile a published PinaPod source instead of a temporary branch revision.

Update every machine consumer of the derive name

The rename to PinaPod affects code that reads or writes Rust source. Update these Pina components together:

  • Account, instruction, and event macros that inject the derive.
  • Source parsing that discovers derived enums.
  • Pina’s public traits and PinaPod re-exports.
  • Codama Rust account and instruction renderers.
  • TypeScript codec helpers and their generated filenames.
  • Checked-in Rust, TypeScript, and Dart clients.
  • Snapshots, UI fixtures, examples, and documentation.

A partial rename can compile the program while the CLI silently omits an enum from its IDL. Test enum discovery before relying on generated-client compilation.

Pina’s framework macros inject #[pinapod(crate = pina::pinapod, no_inherent)]. The crate option resolves the runtime re-export. no_inherent leaves method ownership with Pina’s account-aware helpers while preserving the generated trait implementations.

The macros mark account discriminators with #[pinapod(skip_accessor, skip_patch)]. The derive still stores and validates the discriminator, but application patches cannot change framework-owned bytes.

Lift fixed-account collection restrictions

After Pina depends on v0.2, allow fixed accounts to contain bounded strings, vectors, options, and recursively bounded combinations. Move the old compile-fail fixtures for these fields to pass fixtures.

Pina must calculate a generic fixed representation with size_of::<<T as PinaPodFixed>::Zc>(). Its account-aware API can expose that result as inherent AccountType::SIZE. It must not recalculate nested sizes from syntax or accept capacity from an untrusted documentation string.

Use one validation boundary

Pina checks account ownership, the discriminator, writable status, and Solana-specific resize and rent rules. PinaPod checks the compact allocation against the schema’s minimum, maximum, and tail granularity, validates the representation, and returns the view. Do not validate with PinaPod and then call a second validating constructor.

Mutable account loaders must reject a non-writable account before they return a mutable view. Drop every account-data guard before a resize or CPI, then borrow and parse the new allocation again.

Update compact accounts with one patch

Pina’s resizable-account builder accepts the generated patch and manages the borrow and resize order:

#![allow(unused)]
fn main() {
UpdateResizableAccount {
	account: self.journal,
	rent_account: self.authority,
	program_id: &ID,
	patch: JournalPatch::new()
		.revision(next_revision)
		.replace_entries(&entries)
		.note(Some("Updated")),
}
.invoke::<Journal>()?;
}

The field is named rent_account, matching Pina’s other reallocation builders. The builder performs these steps:

  1. Borrow and validate the current account.
  2. Calculate the patched encoded length without changing bytes.
  3. Release the borrow.
  4. Grow the allocation when required.
  5. Borrow and validate the resized account.
  6. Apply the same patch once.
  7. Release the borrow.
  8. Shrink the allocation when required.

The patch borrows its input strings and slices. It does not borrow account data, so it can survive the resize between planning and application.

Keep generated clients at the same boundary

Carry capacities as structured IDL data. Do not recover N from prose. Generated Rust, TypeScript, and Dart encoders and decoders must reject:

  • A string or vector above its declared capacity.
  • A length prefix that exceeds the available bytes.
  • Invalid UTF-8.
  • An option tag other than zero or one.
  • A boolean byte other than zero or one.
  • An account allocation outside the schema’s compact size policy.

Use shared golden and malformed fixtures across all three languages. Include None, Some(empty), multiple unequal tails, the maximum capacity, one item over capacity, and Vec<String<M>, N> with different logical string lengths.

Publish the matching Pina guide

The Pina pull request owns framework-specific instructions. Update its mdBook, crate readmes, source templates, examples, agent references, and migration guide before merging. Run Pina’s documentation sync command after editing source templates so generated docs cannot retain the v0.1 API.

Supported toolchains and Solana versions

PinaPod exists for Solana account data, so its supported-compiler window follows the Solana ecosystem rather than the newest stable Rust.

MSRV policy

  • The minimum supported Rust version (MSRV) is the oldest Rust pinned by an Agave line that Pina targets. The repository pins the exact MSRV in rust-version and runs the full test suite against it in CI.
  • PinaPod does not chase every Agave toolchain bump. The MSRV moves only when the supported Solana window moves, and never sooner than needed.
  • An MSRV bump is announced as a breaking change with a migration note in the changelog. The Agave table below records the current alignment.
  • Development happens on the pinned nightly in rust-toolchain.toml (for Miri), but nightly is never required: CI runs the full suite on that nightly, on current stable, and on the MSRV. Compile-fail UI snapshots are recorded on the pinned nightly; other toolchain jobs skip them with PINAPOD_UI=skip.

Agave Rust versions

Each row gives the Rust channel pinned by rust-toolchain.toml at that Agave release tag. PinaPod’s MSRV of 1.89 covers every Agave line from the late v3.x era onward; older lines predate the supported window.

Agave linePinned RustPinaPod MSRV 1.89 covers
v2.11.81.0no (EOL)
v2.21.84.1no (EOL)
v2.3–v3.11.86.0no (EOL)
v3.2 era1.89.0yes (floor)
v3.3 era1.90.0yes
v4.01.93.1yes
v4.11.95.0yes
v4.21.96.1yes
v4.31.97.1yes

When Agave retires the last line that needs a given Rust version, the next PinaPod breaking release may raise the MSRV to the new floor. The table is refreshed from anza-xyz/agave history at that time.

Solana dependency ranges

The optional solana-address and solana-program-error integrations accept solana-address >= 2.2, < 2.7 and solana-program-error >= 3.0, < 4.0. These ranges describe the SDK type-compatibility window for the optional features, not the supported toolchain window. Building those features still requires a compiler that satisfies the PinaPod MSRV. The cap is deliberate: major and minor Agave SDK lines are only widened after their representatives are tested, and the range is revisited together with the table above.

Versioning

Versions and changelogs are managed by MonoChange through .changeset/*.md intent files. MonoChange also provides the project’s semantic-version check in place of cargo-semver-checks: every pull request runs the changeset-policy workflow, which enforces changeset coverage and posts a semantic change classification with the proposed bump per package, and the release preview derives compatibility evidence from the semantic diff. Never edit CHANGELOG.md by hand; it is rendered from changesets during the release flow.

API comparison benchmarks

pinapod/benches/api_comparison.rs compares three implementations: the PinaPod code in this checkout, previous PinaPod at commit 71ad8bee53e3e6939fe14760e539942d0f1bdd77, and upstream blueshift-gg/zeropod at commit 78e6e5f4b515e85999bcc719eb8db59d3ca11b13 (v0.3.5). Cargo records both exact revisions in Cargo.lock; the benchmark and scripts always use --locked.

The benchmark derives separate, wire-identical schemas for every implementation. It asserts equality of their fixed and compact encodings before collecting samples, including compact vector counts of 1, 4, 8, and 16. A mismatch therefore fails instead of producing a misleading performance comparison. The normal integration suite repeats this three-way wire check, so cargo test catches compatibility regressions without running Criterion. Criterion labels the three contenders as pinapod-current, pinapod-previous-71ad8be, and zeropod-upstream-78e6e5f.

WorkloadWire bytesWhat is timed
Fixed45Parse, validation-only, read four fields from a validated view, mutate a valid value, or initialize a value
Compact small36Parse, validation-only, access a five-byte string and two u64 values, update from a small record
Compact maximum207Parse, validation-only, access a 64-byte string and 16 u64 values, grow a small record to maximum capacity
Many-tail fields145Parse plus access of every field, or only the last field, on a six-tail compact schema

The many-tail fixture guards reader and update scaling with the number of tail fields rather than the number of elements. Each accessor walks the preceding length prefixes, and the update preflight walks the current tails once with locals — measured on-chain (SBF), per-accessor offset caching cost more compute than it returned, so 0.3.1 keeps accessors uncached and the preflight flat. The fixture times parse, a full six-field sweep, and a last-field-only read so the scaling stays visible.

The harness prints the fixed/header/encoded sizes, generated view sizes, and allocation counts for representative PinaPod writes and updates. It uses fixed stack buffers and prebuilt inputs, so any reported allocation comes from the implementation rather than benchmark-buffer setup.

The compact writer types differ by API generation. The current fixture measures the generated CompactPatch and reports current ref=.../patch=.... The pinned previous and upstream fixtures measure their generated mutable views and report ref=.../mut=.... The workload and wire bytes remain the same; the labels make the compared API shapes explicit.

Deliberate costs that must not be optimized away

Several PinaPod operations do more byte work than a naive implementation because the extra writes are load-bearing for security:

  • Shortening a string, vector, or compact tail zeroes the removed bytes.
  • Absent option payloads are zeroed when cleared and never serialized.
  • Compact updates zero the old suffix when the encoded value shrinks.
  • Fixed and compact initialization zero the destination before and after a failed attempt.

These writes prevent stale account data from leaking through inactive capacity later, and they make repeated writes produce identical bytes. Stale-capacity disclosure is in the SECURITY.md threat model. A performance change that removes a zero-fill is a security regression, not an optimization.

v0.2 release-candidate results

The table below reports median latency from GitHub Actions run 34127045383 on 7 September 2026. A positive score means the current implementation is faster than PinaPod v0.1; a negative score means it is slower. The score is (previous - current) / previous, so its sign follows performance rather than elapsed time.

WorkloadPinaPod v0.2PinaPod v0.1ZeroPod v0.3.5Performance score
Fixed parse1.406 ns1.406 ns1.406 ns+0.02%
Fixed validation0.703 ns0.703 ns0.703 ns0.00%
Fixed read3.516 ns3.515 ns3.514 ns-0.01%
Fixed mutation2.044 ns2.082 ns2.036 ns+1.80%
Fixed initialization1.979 ns2.023 ns2.024 ns+2.18%
Compact-small parse7.032 ns6.682 ns6.682 ns-5.24%
Compact-small validation6.426 ns5.977 ns5.975 ns-7.51%
Compact-small access2.108 ns2.108 ns2.108 ns+0.01%
Compact-small update17.231 ns16.390 ns16.500 ns-5.13%
Compact-maximum parse12.665 ns12.339 ns12.436 ns-2.65%
Compact-maximum validate12.694 ns11.979 ns11.983 ns-5.97%
Compact-maximum access2.108 ns2.108 ns2.108 ns+0.01%
Compact-maximum update18.282 ns17.769 ns17.666 ns-2.89%

The fixed path is effectively unchanged and its safe one-pass initializer is 2.18% faster than the historical zero-buffer setup. Compact access is also unchanged. Compact parse, validation, and atomic update add between 2.65% and 7.51% in these representative records. That cost buys allocation-bound validation, checked offset arithmetic, preflighted all-or-nothing updates, and stale-suffix clearing. The scaling fixtures show that compact access and validation remain flat as the vector grows; the fixed validation overhead is not proportional to active element count.

The GitHub-hosted job runs all three implementations in one Criterion process on ubuntu-24.04 with the checked-in Rust toolchain and Cargo.lock. It uses Criterion 0.5.1 with 100 samples, a three-second warmup, and a five-second measurement. The job uploads the complete target/criterion directory as a pinapod-api-comparison-<run>-<attempt> artifact for 14 days. This preserves the raw estimates, distributions, and HTML report used to populate the final release table.

PinaPod v0.2 fixed initialization zeroes the destination before configuration and validates the finished value. PinaPod v0.1 and upstream ZeroPod have no equivalent safe initializer, so their comparison workload is the historical operation available to a caller with a new zeroed buffer: validate that buffer, take a mutable view, and write the fields. Fixed mutation remains a separate apples-to-apples workload.

The compact scaling groups hold the string tail at five bytes and vary the u64 vector across 1, 4, 8, and 16 values. The harness also records allocation counts for fixed mutation, fixed initialization, compact-small update, and compact-maximum update. It reports generated reference and writer/patch object sizes alongside the wire sizes.

This standalone harness measures native host latency, throughput, allocations, generated stack-object sizes, and encoded byte counts. Encoded bytes are relevant to Solana rent, reallocation, and copy volume, but this harness does not measure SBF compute units. The downstream Pina integration suite is the right place for an SBF program-test compute-unit regression because Pina owns the account borrow, resize, and CPI lifecycle.

Run

From the repository root, run the full comparison:

devenv shell bench:compare

Criterion reports both latency and throughput. Local results live under target/criterion/. Pull requests that change the benchmark, runtime, derive implementation, Cargo graph, Rust toolchain, or development environment run the same locked comparison on GitHub-hosted hardware and upload that directory as an artifact.

Before changing PinaPod, save the current measurements under the stable baseline name:

devenv shell bench:compare:baseline

After the change, compare the same checkout and machine against that saved baseline:

devenv shell bench:compare:after

Use a quiet machine, a release build, and the same target triple for both runs. Do not update either historical git revision, fixture values, or workload byte sizes while evaluating a PinaPod implementation change. If the v0.2 public API changes, adapt only the pinapod-current half of the harness so the two pinned fixtures continue to define the historical workload and wire baseline.