Pina
Pina is a high-performance Solana smart-contract framework built on top of pinocchio. The project focuses on low compute-unit usage, small dependency surface area, and strong account validation ergonomics for on-chain Rust programs.
This book is the single place for project documentation. It complements API reference docs by describing architecture, patterns, workflows, and quality standards used across the repository.
What you get in this book
- The project’s goals and trade-offs.
- Setup and day-to-day development workflow.
- Core framework concepts (
#[account],#[instruction],#[derive(Accounts)], discriminator model, and validation chains). - Architecture decision records for the project’s long-lived invariants and trade-offs.
- Codama IDL/client-generation workflow (including external-project invocation).
- Guidance for examples and security-focused development.
- CI/release pipeline expectations.
- A practical recommendations roadmap for improving goal alignment.
Project Goals
Pina’s codebase currently optimizes for the following goals.
1. Performance and low compute units
- Prefer
pinocchioprimitives over heavier Solana SDK surfaces. - Minimize instruction overhead by using zero-copy layouts and typed discriminators.
- Keep runtime checks explicit but lightweight.
2. no_std-first smart contract ergonomics
- Keep crates deployable to Solana SBF targets.
- Avoid patterns that introduce allocator/runtime assumptions.
- Gate entrypoint-specific behavior behind features.
3. Safety for account handling and state transitions
- Strong discriminator and owner checks.
- Explicit validation chains for signer, writable, PDA seeds, and type.
- Defensive arithmetic and transfer operations.
4. Macro-powered developer experience
- Reduce boilerplate with
#[account],#[instruction],#[event],#[error], and#[derive(Accounts)]. - Keep generated behavior predictable, documented, and tested.
5. Maintainability and release quality
- Reproducible dev environments (
devenv+ pinned tooling). - CI coverage for linting, tests, and builds.
- Changelog-driven release discipline via changesets.
Getting Started
Prerequisites
- Rust nightly toolchain from
rust-toolchain.toml devenv(Nix-based environment)gh(for GitHub workflows)
Setup
devenv shell
install:all
If pnpm-workspace.yaml sets useNodeVersion, devenv shell activates the matching pnpm-managed node/npm/npx/corepack toolchain automatically.
Build and test
cargo build --all-features
cargo test
Common quality checks
lint:clippy
lint:format
verify:docs
Generate a Codama IDL
pina idl --path ./examples/counter_program --output ./codama/idls/counter_program.json
See Codama Workflow for end-to-end generation and external-project usage.
Build this documentation
docs:build
The generated site is written to docs/book/.
Core Concepts
Discriminator layout (raw bytes)
Pina stores discriminator bytes directly in the struct itself as the first field of every #[account], #[instruction], and #[event] type. This is a discriminator-first layout, not an external header.
At runtime this means the parser does a fixed-byte read + size_of::<T>() validation, then a zero-copy cast.
offset | size | meaning
------ | ---- | -------
0 | N | discriminator (N = BYTES of enum primitive: 1/2/4/8)
N | ... | payload fields
This contract is what enables:
- deterministic
size_of::<T>()checks, - zero-copy validation with
as_account()/try_from_bytes(), - alignment-safe offsets for fixed-size Pod fields.
Why this is safer than implicit external headers
External fixed-size headers require manual casting logic in each parse path and make compiler-assist checks harder. With auto-injected first-field discriminators, the compiler can guarantee the exact struct layout and validate it in type-checked assertions.
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.
Discriminator and payload versioning
| Change | Compatibility impact |
|---|---|
| Add a new enum variant | Usually backward-compatible if old clients ignore unknown variants |
| Change an existing variant value | Breaking for every historical byte slice |
| Reorder or remove struct fields | Breaking (offsets change) |
| Append fields to a struct | Mostly non-breaking, but consumers must accept the larger size |
Switch primitive width (u8 → u16, etc.) | Breaking for serialized payloads at that boundary |
For on-chain accounts, treat layout as part of protocol ABI:
- Keep field order stable.
- Introduce optional
versionfields at the tail for in-place migration strategies. - Never change existing discriminator values in place.
- When incompatible layout changes are required, perform explicit migration with a new account version and an operator upgrade flow.
For instruction payloads:
- Prefer additive migration: add a new variant and keep legacy handlers for a release cycle.
- Reject stale payload shapes with explicit errors rather than silently reinterpreting bytes.
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.
Zero-copy account models
#[account] and #[instruction] generate Pod/Zeroable-compatible layouts for in-place reinterpretation of account/instruction bytes.
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.
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.
Instruction authoring tips
- Entry points should accept
&mut [AccountView]and dispatch withAccounts::try_from(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. - Keep
assert_writable()explicit even on&mut AccountView. Type-level mutability unlocks mutable APIs, but the runtime still decides whether the account is writable for the current instruction. 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.- 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.
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 #[repr(transparent)] over byte arrays (or u8 for PodBool) and implement bytemuck::Pod + bytemuck::Zeroable.
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:
- Flash loan guards — verify the current instruction is not being invoked via CPI (
assert_no_cpi) - 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)
Architecture decision records
This section captures the durable architectural decisions behind Pina’s public model, safety posture, and verification strategy.
ADR format and naming
- Files live under
docs/src/adrs/. - ADRs use the naming pattern
NNNN-short-slug.md. - The starter template lives at
docs/src/adrs/0000-template.md. - Architecture-impacting pull requests should link the ADR they follow or update.
ADR index
| ADR | Status | Decision |
|---|---|---|
| ADR 0001 | Accepted | Keep discriminator bytes as the first field inside typed layouts. |
| ADR 0002 | Accepted | Keep zero-copy for fixed-size Pod layouts, but only behind explicit validation. |
| ADR 0003 | Accepted | Keep runtime borrow guards alive for the full typed loader lifetime. |
| ADR 0004 | Accepted | Preserve no_std / no-allocator constraints for on-chain code paths. |
| ADR 0005 | Accepted | Keep SPL token support optional and feature-gated. |
| ADR 0006 | Accepted | Treat CI as layered verification, not a single all-purpose test lane. |
How to use this section
Use these ADRs when you need to answer questions like:
- why Pina uses discriminator-first layouts instead of external headers
- when zero-copy is allowed, and where the safety boundaries are
- why typed account loaders must be guard-backed instead of returning bare references
- why
no_stdand allocator constraints are treated as architecture, not implementation detail - why token helpers are optional instead of always-on
- why Miri, compile-fail tests, feature matrices, and compute-unit checks all exist at once
ADR 0001: Keep discriminator-first typed layouts
- Status: Accepted
- Date: 2026-04-18
- Deciders: Pina maintainers
- Related: Core concepts, Security model
Context
Pina’s account, instruction, and event types are designed around fixed-size, zero-copy layouts.
That only works if the byte contract is explicit and stable. The project needs a layout model that is easy to validate at runtime, easy to generate through Codama, and hard to reinterpret accidentally.
Decision
Pina keeps discriminator bytes as the first field inside every typed #[account], #[instruction], and #[event] layout.
The discriminator width is part of the ABI and is limited to u8, u16, u32, or u64.
From that decision follow a few rules:
- discriminator values are part of the protocol contract
- field order is part of the protocol contract
- widening or changing discriminator values is a breaking change
- incompatible layout changes require explicit migration instead of in-place reinterpretation
Consequences
Benefits:
- runtime validation can do a fixed discriminator read plus
size_of::<T>()checks - generated Rust clients can match on-chain layouts exactly
- zero-copy parsing stays simple and predictable
- the compatibility surface is easy to explain in docs and reviews
Costs:
- account and instruction layouts must be treated like ABI, not ordinary Rust structs
- field reordering and in-place discriminator changes become explicit migrations
- compatibility with systems that expect external discriminator headers needs adapters, not silent reuse
Alternatives considered
External discriminator headers
Rejected because they split the byte contract across a manual header parser plus a typed payload parser. That makes validation paths less uniform and weakens compiler-assisted layout guarantees.
Dynamic serialization formats
Rejected because they add copy/parse overhead, complicate no_std use, and weaken the fixed-layout guarantees that Pina uses for predictable compute costs.
ADR 0002: Keep zero-copy behind explicit validation
- Status: Accepted
- Date: 2026-04-18
- Deciders: Pina maintainers
- Related: Security model,
security/loaders-audit.md
Context
Low compute usage is a stated project goal, and zero-copy account access is one of the biggest reasons to use Pina instead of heavier Solana framework stacks.
But zero-copy is only defensible when the layout contract is tight. Unsafe or dynamically shaped reinterpretation can erase the very safety properties the framework is supposed to enforce.
Decision
Pina keeps zero-copy account and instruction handling as a core design choice, but only for fixed-size layouts that are validated before reinterpretation.
In practice that means:
- zero-copy types must be fixed-size and Pod-compatible
- typed loads must validate discriminator, size, and relevant account identity constraints before use
- dynamic, variable-length, or schema-driven reinterpretation is out of scope for the core loader model
- performance-motivated
unsafeis only acceptable when the soundness boundary is narrow and documented
Consequences
Benefits:
- no heap copies are required for common account access paths
- account parsing stays predictable in both runtime cost and memory behavior
- the framework can keep
no_stdand low-dependency goals without abandoning typed APIs
Costs:
- some data models must use explicit versioning or companion accounts instead of variable-length in-place layouts
- loader APIs need stronger lifetime coupling than a simple
&Treturn type can provide - future extensions must prove they preserve layout and aliasing safety, not just correctness in happy-path tests
Alternatives considered
Copy-based deserialization into owned structs
Rejected because it adds compute overhead, increases stack or heap pressure, and gives up one of Pina’s primary performance advantages.
Unsafe dynamic zero-copy for arbitrary layouts
Rejected because it makes soundness depend on ad-hoc caller discipline and scattered invariants instead of framework-level rules.
ADR 0003: Typed account loaders must be guard-backed
- Status: Accepted
- Date: 2026-04-18
- Deciders: Pina maintainers
- Related:
security/loaders-audit.md, #120, #121, #122
Context
The loader audit identified a high-severity soundness problem: returning plain &T or &mut T from temporary runtime borrow guards lets the guard drop before the typed reference stops being used.
That escaped-borrow pattern affected both generic account loaders and token helper loaders.
Decision
Typed account loader APIs must keep the runtime borrow guard alive for the full lifetime of typed access.
The reference shape for this decision is a guard-backed wrapper such as:
LoadedAccount<'a, T>for immutable typed accessLoadedAccountMut<'a, T>for mutable typed access
These wrappers retain the underlying pinocchio::account::Ref / RefMut and expose the typed account through Deref / DerefMut instead of returning bare references.
The same rule applies to token and ATA helper loaders. Safe owner or address validation is necessary, but it is not a substitute for keeping the borrow guard alive.
Consequences
Benefits:
- overlapping mutable and immutable borrows fail through the runtime borrow model instead of becoming aliasing bugs
- zero-copy access is preserved without severing lifetime coupling
- token helper APIs follow the same soundness model as generic account loaders
Costs:
- this is a breaking public API change for typed loader return values
- helper traits and examples must use wrapper types instead of assuming raw
&T/&mut T - regression coverage needs Miri and borrow-specific tests, not only ordinary functional tests
Alternatives considered
Keep returning bare references and document the caveat
Rejected because soundness bugs are not acceptable as documentation-only footguns.
Copy account data into owned values
Rejected because it throws away the zero-copy design goal and still does not solve the core runtime borrowing contract for mutation.
ADR 0004: Preserve the no_std and no-allocator boundary
- Status: Accepted
- Date: 2026-04-18
- Deciders: Pina maintainers
- Related: Crates and features, Core concepts
Context
Pina targets on-chain Solana programs first. Those programs run in a restricted execution environment where dependency surface area, allocator behavior, and binary size all matter.
Treating no_std as optional or allowing heap-heavy code paths in core runtime APIs would weaken both the performance story and the predictability of on-chain behavior.
Decision
Pina treats no_std compatibility and allocator avoidance as architecture, not convenience.
That means:
- on-chain crates must compile for SBF without requiring
std - host-only conveniences stay behind
cfg(test)or explicit host build guards - fixed-size Pod layouts, stack data, and borrow-based APIs are preferred over heap allocation in instruction paths
- workspace rules continue to deny
unsafe_codeandunstable_featuresby default
Consequences
Benefits:
- on-chain programs keep a small and predictable runtime surface
- developers can reason about cost and failure modes without hidden allocator behavior
- examples and generated clients stay aligned with the actual on-chain target model
Costs:
- some otherwise ergonomic Rust libraries are unsuitable for core runtime paths
- host/test helper code often needs explicit cfg boundaries
- API design has to favor fixed-size, deterministic shapes over flexible heap-backed ones
Alternatives considered
Allow a general allocator in core on-chain paths
Rejected because it increases binary and behavioral complexity without helping the framework’s main goals.
Treat no_std as a best-effort property only
Rejected because CI, examples, and public APIs would drift toward host assumptions over time.
ADR 0005: Keep token support optional and feature-gated
- Status: Accepted
- Date: 2026-04-18
- Deciders: Pina maintainers
- Related: Crates and features, #121, #126
Context
Many Solana programs need SPL token, Token-2022, or ATA helpers, but many do not. Making token support unconditional would increase the dependency surface and blur the boundary between core framework validation and token-specific conveniences.
At the same time, token helpers need to be first-class when the feature is enabled, including correct owner validation and Token-2022 compatibility.
Decision
Pina keeps token support behind the optional token feature.
From that decision follow a few rules:
- core account validation and zero-copy APIs must compile and remain useful without
token - SPL token, Token-2022, and ATA helpers live behind the feature gate
- checked token loaders must validate owner and account-identity constraints before typed access
- feature-matrix CI must continue to cover at least default, no-default, token-only, and all-features configurations
Consequences
Benefits:
- non-token programs do not pay dependency or API complexity they do not need
- token-heavy programs still get ergonomic helpers once they opt in
- feature-matrix testing becomes an explicit compatibility contract instead of a best effort
Costs:
- docs and tests must describe feature boundaries clearly
- token-related APIs must be careful not to leak assumptions into core no-feature paths
- compatibility work for Token-2022 needs dedicated coverage instead of being assumed by SPL token support
Alternatives considered
Make token support part of the default feature set
Rejected because it increases the default dependency surface and weakens the project’s minimal-core story.
Move token support entirely out of pina
Rejected for now because the helpers are part of the framework’s core ergonomics, but the dependency cost still needs to stay opt-in.
ADR 0006: Use layered verification in CI
- Status: Accepted
- Date: 2026-04-18
- Deciders: Pina maintainers
- Related: CI and releases, #122, #124, #125, #126
Context
Pina makes claims about safety, compatibility, and performance. Those claims are not covered by a single kind of test.
Ordinary unit and integration tests catch many behavioral regressions, but they do not cover undefined behavior, macro diagnostics, feature-flag drift, generated-client drift, or compute-unit regressions on their own.
Decision
Pina treats CI as layered verification.
The expected layers are:
- standard tests for behavior and regression coverage
- feature-matrix checks for compatibility across supported configurations
- compile-fail tests for proc-macro diagnostics
- Miri for borrow and undefined-behavior regressions in sensitive loader paths
- IDL and generated-client verification for schema stability
- security verification and repository-specific linting
- binary-size and compute-unit reporting for performance drift
Static pina profile comparisons are the default CI mechanism for compute-unit regression reporting because they are deterministic and stable for PR-vs-base comparison.
Consequences
Benefits:
- each high-risk bug class has a matching verification layer
- safety and performance claims stay enforceable in pull requests instead of only in release notes
- contributors can tell which lane failed and what class of invariant it protects
Costs:
- CI takes longer and is more operationally complex
- some lanes need careful threshold tuning to avoid noisy failures
- performance verification remains approximate unless paired with deeper runtime benchmarking
Alternatives considered
Rely on cargo test alone
Rejected because it leaves entire bug classes untested, especially macro diagnostics, UB regressions, and feature-flag drift.
Use only runtime performance measurements
Rejected because runtime measurements are noisier and harder to compare deterministically in PR CI than static profiler output.
Crates and Features
| Crate | Path | Description |
|---|---|---|
pina | crates/pina | Core framework — traits, account loaders, CPI helpers, Pod types. |
pina_macros | crates/pina_macros | Proc macros — #[account], #[instruction], #[event], etc. |
pina_cli | crates/pina_cli | CLI/library for IDL generation, Codama integration, scaffolding. |
pina_codama_renderer | crates/pina_codama_renderer | Repository-local Codama Rust renderer for Pina-style clients. |
pina_pod_primitives | crates/pina_pod_primitives | no_std POD primitives — integer/bool wrappers, fixed-capacity collections. |
pina_profile | crates/pina_profile | Static CU profiler for compiled SBF programs. |
pina_sdk_ids | crates/pina_sdk_ids | Typed constants for well-known Solana program/sysvar IDs. |
crates/pina
Core runtime crate for on-chain program logic.
Includes:
AccountViewand validation chain helpers.- Typed account loaders and discriminator checks.
- CPI/system/token helper utilities.
nostd_entrypoint!and instruction parsing helpers.- Instruction introspection (flash loan guards, sandwich detection).
- Pod types with full arithmetic operator support.
Feature flags:
| Feature | Default | Description |
|---|---|---|
derive | Yes | Enables proc macros (#[account], #[instruction], etc.) |
logs | Yes | Enables on-chain logging via solana-program-log |
token | No | Enables SPL token / token-2022 helpers and ATA utilities |
memo | No | Enables memo program helpers via pina::memo |
account-resize | No | Enables account realloc helpers that call Pinocchio resize APIs |
Feature selection tips
deriveis the normal choice for program crates; disable it only when you want the low-level runtime traits without the proc macros.logsis useful during initial development and debugging, testing, and audits. Disable it when you want the smallest possible binary or completely silent runtime failures.tokenenablespina::token,pina::token_2022,pina::associated_token_account, and theTokenAccountcompatibility aliases over the upstream renamed account types.memois separate fromtoken, so memo CPI support can be enabled without pulling in the token helper surface.account-resizeonly unlocks realloc helpers such asrealloc_account()andrealloc_account_zero(). Close helpers still do not implicitly resize or zero account data.
See ADR 0004 and ADR 0005 for the architectural rationale behind these feature and runtime boundaries. For concrete token CPI patterns, see Token CPI Recipes.
crates/pina_macros
Proc-macro crate used by pina.
Provides:
#[discriminator]#[account]#[instruction]#[event]#[error]#[derive(Accounts)]
crates/pina_cli
Developer CLI and library.
Commands:
| Command | Description |
|---|---|
pina init <name> | Scaffold a new Pina program project |
pina idl --path <dir> | Generate a Codama IDL JSON from a Pina program |
pina profile <path.so> | Static CU profiler for compiled SBF binaries |
pina codama generate | Generate Codama IDLs and Rust/JS clients for examples |
The IDL parser supports multi-file programs — it follows mod declarations from src/lib.rs to discover accounts, instructions, and discriminators across all source files.
Library surface:
pina_cli::generate_idl(program_path, name_override)pina_cli::init_project(path, package_name, force)
crates/pina_pod_primitives
no_std crate containing alignment-safe POD primitive wrappers (PodBool, PodU*, PodI*) and fixed-capacity collection types (PodOption, PodString, PodVec) shared by pina and generated clients.
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.
| Type | Purpose | Layout |
|---|---|---|
PodOption<T: Pod> | Fixed-size Option<T> | 1-byte discriminant + T |
PodString<N, PFX=1> | Fixed-capacity string | PFX-byte length prefix + N data bytes |
PodVec<T: Pod, N, PFX=2> | Fixed-capacity vec | PFX-byte length prefix + N elements |
All collection types are #[repr(C)], alignment-1, and implement bytemuck::Pod + bytemuck::Zeroable. Length prefixes (PFX) default to 1 byte for strings (max 255) and 2 bytes for vectors (max 65 535 elements).
Collection types store data inline with a length prefix, enabling zero-copy access inside #[repr(C)] account structs. Overflow is detected at insertion time — try_set / try_push return Err(PodCollectionError::Overflow) when capacity is exceeded.
PodString provides UTF-8 validation via try_as_str(), while PodVec offers slice-based access via as_slice() / as_mut_slice(). PodOption mirrors the Option<T> API with get(), set(), and clear().
crates/pina_profile
The pina profile command analyzes compiled SBF .so binaries to estimate per-function compute unit costs without requiring a running validator.
pina profile target/deploy/my_program.so # text summary
pina profile target/deploy/my_program.so --json # JSON for CI
pina profile target/deploy/my_program.so -o r.json # write to file
The profiler decodes each SBF instruction opcode and assigns costs: regular instructions cost 1 CU, syscalls cost 100 CU.
crates/pina_codama_renderer
Repository-local renderer that generates Pina-style Rust client code from Codama JSON IDLs. The renderer is organized into focused modules under src/render/:
accounts.rs— account page and PDA helpersinstructions.rs— instruction page, account metastypes.rs— Pod type rendering, defined typeserrors.rs— error page renderingdiscriminator.rs— discriminator renderingseeds.rs— seed parameter/constant rendering
Use this when you want generated Rust models to match Pina’s fixed-size discriminator-first/bytemuck conventions.
crates/pina_sdk_ids
no_std crate that exports well-known Solana program/sysvar IDs as typed constants.
Use this crate to avoid hardcoded base58 literals in validation logic.
Codama Workflow
This repository uses Codama as the IDL and client-generation layer for Pina programs.
The flow has three stages:
- Generate Codama JSON from Rust programs (
pina idl). - Validate generated JSON against committed fixtures/tests.
- Render clients (JS with Codama renderers, Rust with
pina_codama_renderer).
In This Repository
Generate and validate the whole workspace flow with devenv scripts:
# Generate Codama IDLs for all examples.
codama:idl:all
# Generate Rust + JS clients.
codama:clients:generate
# Generate IDLs + Rust/JS clients in one command.
pina codama generate
# Run the complete Codama pipeline.
codama:test
# Run IDL fixture drift + validation checks used by CI.
test:idl
# Run Quasar SVM generated-client e2e checks alongside LiteSVM.
pnpm run test:quasar-svm
Supporting scripts:
scripts/generate-codama-idls.sh: regeneratescodama/idls/*.jsonfixtures for all examples.scripts/verify-codama-idls.sh: regenerates IDLs/clients, verifies fixtures via Rust and JS tests, and enforces deterministic no-diff output.
In a Separate Project
You do not need to copy this entire repository to use Codama with Pina.
1. Generate IDL from your program
pina idl --path ./programs/my_program --output ./idls/my_program.json
2. Generate JS clients with Codama
pnpm add -D codama @codama/renderers-js
import { renderVisitor as renderJsVisitor } from "@codama/renderers-js";
import { createFromFile } from "codama";
const codama = await createFromFile("./idls/my_program.json");
await codama.accept(renderJsVisitor("./clients/js/my_program"));
3. Generate Pina-style Rust clients (optional)
This repository ships crates/pina_codama_renderer, which emits Rust models aligned with Pina’s discriminator-first, fixed-size POD layouts.
cargo run --manifest-path ./crates/pina_codama_renderer/Cargo.toml -- \
--idl ./idls/my_program.json \
--output ./clients/rust
You can pass multiple --idl flags or --idl-dir.
Renderer Constraints
pina_codama_renderer intentionally targets fixed-size layouts. Unsupported patterns produce explicit errors (for example variable-length strings/bytes, unsupported endian/number forms, and non-fixed arrays).
Extractor coverage
The extractor currently supports these dispatch shapes:
- Canonical routed arms:
Variant => Accounts::try_from(accounts)?.process(data) - Grouped routed arms:
VariantA | VariantB => SharedAccounts::try_from(accounts)?.process(data) - Accountless arms:
Variant => { let _ = Payload::try_from_bytes(data)?; Ok(()) } - Instruction-only fallback: if Pina finds
#[instruction]structs but no recognizable dispatch map, it still emits zero-account instruction nodes from those payload structs.
Keep in mind:
- Account metadata is only inferred for routed
Accounts::try_from(accounts)arms. - Signer/PDA/default-account inference still depends on direct
self.field.assert_*()chains insideimpl ProcessAccountInfos. - Writable inference comes from either direct
assert_writable()chains or mutable#[derive(Accounts)]fields such as&'a mut AccountView. - If you hide routing or validation behind helper layers, instruction nodes may still exist, but account metadata becomes less complete.
Source shapes that extract cleanly
Use the same program shapes described in crates/pina_cli/rules.md to keep IDL extraction predictable.
Multi-file layout
#![allow(unused)]
fn main() {
// src/lib.rs
use pina::*;
mod accounts;
mod instructions;
mod pda;
mod state;
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
}
Canonical dispatch
#![allow(unused)]
fn main() {
#[cfg(feature = "bpf-entrypoint")]
pub mod entrypoint {
use super::*;
nostd_entrypoint!(process_instruction);
pub fn process_instruction(
program_id: &Address,
accounts: &mut [AccountView],
data: &[u8],
) -> ProgramResult {
let ix: MyInstruction = parse_instruction(program_id, &ID, data)?;
// Prefer one routed arm per variant when possible.
match ix {
MyInstruction::Initialize => InitializeAccounts::try_from(accounts)?.process(data),
MyInstruction::Update => UpdateAccounts::try_from(accounts)?.process(data),
}
}
}
}
Grouped dispatch with shared accounts
#![allow(unused)]
fn main() {
match ix {
MyInstruction::Initialize => InitializeAccounts::try_from(accounts)?.process(data),
MyInstruction::Toggle | MyInstruction::Update => {
UpdateAccounts::try_from(accounts)?.process(data)
}
}
}
Accountless dispatch
#![allow(unused)]
fn main() {
match ix {
MyInstruction::Ping => {
let _ = PingInstruction::try_from_bytes(data)?;
Ok(())
}
MyInstruction::Initialize => InitializeAccounts::try_from(accounts)?.process(data),
}
}
Validation chains
#![allow(unused)]
fn main() {
impl<'a> ProcessAccountInfos<'a> for InitializeAccounts<'a> {
fn process(self, data: &[u8]) -> ProgramResult {
let args = InitializeInstruction::try_from_bytes(data)?;
let seeds = my_seeds!(self.authority.address().as_ref(), args.bump);
self.authority.assert_signer()?;
self.system_program.assert_address(&system::ID)?;
self.token_program.assert_address(&token::ID)?;
self.ata_program
.assert_address(&associated_token_account::ID)?;
self.state
.assert_empty()?
.assert_writable()?
.assert_seeds_with_bump(seeds, &ID)?;
Ok(())
}
}
}
PDA seed helpers
#![allow(unused)]
fn main() {
const MY_SEED: &[u8] = b"my";
#[macro_export]
macro_rules! my_seeds {
($authority:expr) => {
&[MY_SEED, $authority]
};
($authority:expr, $bump:expr) => {
&[MY_SEED, $authority, &[$bump]]
};
}
}
Discriminators and account layouts
#![allow(unused)]
fn main() {
#[discriminator]
pub enum MyInstruction {
Initialize = 0,
Update = 1,
}
#[discriminator]
pub enum MyAccountType {
MyState = 1,
}
#[instruction(discriminator = MyInstruction, variant = Initialize)]
pub struct InitializeInstruction {
pub bump: u8,
}
#[instruction(discriminator = MyInstruction, variant = Update)]
pub struct UpdateInstruction {
pub value: PodU64,
}
#[account(discriminator = MyAccountType)]
pub struct MyState {
pub bump: u8,
pub value: PodU64,
}
}
For the full checklist and rationale, see crates/pina_cli/rules.md.
CI Coverage
test:idl treats the generated IDL as an API contract. It checks that:
- every example regenerates deterministically into
codama/idls,codama/clients/js, andcodama/clients/rust - generated JSON passes Codama’s JS validator
- generated JS clients typecheck
- generated Rust clients compile
- for every example, generated instruction/account/error counts match the source declarations:
#[instruction]#[account]#[error]
That last count-parity check is important because it catches silent extraction regressions where a program still produces valid JSON, but one or more instruction surfaces disappear.
Examples
The examples/ workspace members demonstrate practical usage patterns:
hello_solana: minimal program structure and instruction dispatch.counter_program: PDA creation, mutation, and account validation.todo_program: PDA-backed state with boolean + digest updates.transfer_sol: lamport transfers and account checks.escrow_program: richer multi-account flow and token-oriented logic.vesting_program: token vesting / lockup scaffold with vault ATA setup and claim/cancel state.role_registry_program: role-based configuration and registry PDAs with admin rotation.staking_rewards_program: staking pool and user-position accounting scaffold with reward bookkeeping.pina_bpf: minimal pina-native BPF hello world with nightlybuild-std=core,alloc.prop_amm_program: Pina-native semantic port of Anchoranchor-nextbenchmarkprop-amm, focused on authority-controlled oracle updates without the upstream asm fast path.anchor_declare_id: first Anchor test parity port, focused on program-id mismatch checks.anchor_declare_program: Anchordeclare-programparity for external-program ID checks.anchor_duplicate_mutable_accounts: explicit duplicate mutable account validation pattern.anchor_errors: Anchor-style custom error code and guard helper parity.anchor_events: event schema parity through deterministic serialization checks.anchor_floats: float data account create/update flow with authority validation.anchor_system_accounts: system-program owner validation parity.anchor_sysvars: clock/rent/stake-history sysvar validation parity.anchor_realloc: realloc growth and duplicate-target safety checks.
Use examples as reference implementations for account layout, instruction parsing, and validation ordering.
Anchor test-suite parity progress is tracked in Anchor Test Porting.
Every example directory includes a local readme.md with purpose, coverage, and run commands.
When adding new examples:
- Keep instruction/account discriminator handling explicit.
- Use checked arithmetic in state transitions.
- Include unit tests and clear doc comments for every instruction path.
Your First Program
This tutorial walks through building a minimal Solana program from scratch using Pina. By the end you will have a working on-chain program that logs a greeting, complete with tests.
Prerequisites
- A working development environment (see Getting Started).
- Basic familiarity with Rust and the Solana account model.
Project setup
Create a new crate inside the workspace (or standalone):
# Cargo.toml
[package]
name = "hello_solana"
version = "0.0.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "lib"]
[features]
bpf-entrypoint = []
[dependencies]
pina = { version = "...", features = ["logs", "derive"] }
The cdylib crate type is required for building a shared library that the Solana runtime can load. The lib type lets tests and other crates consume the program as a regular Rust library.
The bpf-entrypoint feature gates the on-chain entrypoint so that test builds do not pull in BPF-specific machinery.
Step 1: Declare a program ID
Every Solana program has a unique address. declare_id! parses a base58 string into a constant ID of type Address:
#![allow(unused)]
#![no_std]
fn main() {
use pina::*;
declare_id!("DCF5KBmtQ9ryDC7mQezKLwuJHem6coVUCmKkw37M9J4A");
}
The #![no_std] attribute is required for on-chain programs. Pina is designed to work without the standard library so the resulting binary stays small and does not depend on a heap allocator.
For native (non-BPF) builds outside of tests you need a small shim to provide the standard library:
#![allow(unused)]
fn main() {
#[cfg(all(
not(any(target_os = "solana", target_arch = "bpf")),
not(feature = "bpf-entrypoint"),
not(test)
))]
extern crate std;
}
Step 2: Define an instruction discriminator
Pina programs use discriminator enums to identify instruction variants. The #[discriminator] macro generates TryFrom<u8> and the framework’s IntoDiscriminator trait:
#![allow(unused)]
fn main() {
#[discriminator]
pub enum HelloInstruction {
Hello = 0,
}
}
The numeric value (0) becomes the first byte of the serialized instruction data. Clients send this byte so the program knows which handler to invoke.
Step 3: Define instruction data
The #[instruction] macro creates a Pod/Zeroable struct whose first field is an auto-injected discriminator byte. It also generates a TypedBuilder for ergonomic construction in tests:
#![allow(unused)]
fn main() {
#[instruction(discriminator = HelloInstruction, variant = Hello)]
pub struct HelloInstructionData {}
}
This instruction has no extra payload – it only needs the discriminator byte to be identified.
Step 4: Define an accounts struct
#[derive(Accounts)] generates a TryFromAccountInfos implementation that maps positional accounts from the transaction into named fields:
#![allow(unused)]
fn main() {
#[derive(Accounts, Debug)]
pub struct HelloAccounts<'a> {
pub user: &'a AccountView,
}
}
If a transaction supplies fewer accounts than the struct declares, TryFrom returns ProgramError::NotEnoughAccountKeys.
Step 5: Implement the processor
The ProcessAccountInfos trait defines the process method that contains your instruction logic:
#![allow(unused)]
fn main() {
impl<'a> ProcessAccountInfos<'a> for HelloAccounts<'a> {
fn process(self, data: &[u8]) -> ProgramResult {
let _ = HelloInstructionData::try_from_bytes(data)?;
self.user.assert_signer()?;
log!("Hello, Solana!");
Ok(())
}
}
}
try_from_bytes validates that the raw instruction data is the correct size and layout. assert_signer() verifies the user actually signed the transaction. If any check fails the program returns an error and the transaction is rejected.
Step 6: Wire up the entrypoint
The entrypoint module is gated behind bpf-entrypoint so it only compiles for on-chain builds:
#![allow(unused)]
fn main() {
#[cfg(feature = "bpf-entrypoint")]
pub mod entrypoint {
use pina::*;
use super::*;
nostd_entrypoint!(process_instruction);
#[inline(always)]
pub fn process_instruction(
program_id: &Address,
accounts: &mut [AccountView],
data: &[u8],
) -> ProgramResult {
let instruction: HelloInstruction = parse_instruction(program_id, &ID, data)?;
match instruction {
HelloInstruction::Hello => HelloAccounts::try_from(accounts)?.process(data),
}
}
}
}
nostd_entrypoint! wires up the BPF entrypoint, a minimal panic handler, and a no-allocation stub. parse_instruction reads the discriminator byte, verifies the program ID matches, and returns the typed enum variant.
The complete program
Putting it all together (this matches examples/hello_solana/src/lib.rs in the repository):
#![allow(unused)]
#![allow(clippy::inline_always)]
#![no_std]
fn main() {
#[cfg(all(
not(any(target_os = "solana", target_arch = "bpf")),
not(feature = "bpf-entrypoint"),
not(test)
))]
extern crate std;
use pina::*;
declare_id!("DCF5KBmtQ9ryDC7mQezKLwuJHem6coVUCmKkw37M9J4A");
#[discriminator]
pub enum HelloInstruction {
Hello = 0,
}
#[instruction(discriminator = HelloInstruction, variant = Hello)]
pub struct HelloInstructionData {}
#[derive(Accounts, Debug)]
pub struct HelloAccounts<'a> {
pub user: &'a AccountView,
}
impl<'a> ProcessAccountInfos<'a> for HelloAccounts<'a> {
fn process(self, data: &[u8]) -> ProgramResult {
let _ = HelloInstructionData::try_from_bytes(data)?;
self.user.assert_signer()?;
log!("Hello, Solana!");
Ok(())
}
}
#[cfg(feature = "bpf-entrypoint")]
pub mod entrypoint {
use pina::*;
use super::*;
nostd_entrypoint!(process_instruction);
#[inline(always)]
pub fn process_instruction(
program_id: &Address,
accounts: &mut [AccountView],
data: &[u8],
) -> ProgramResult {
let instruction: HelloInstruction = parse_instruction(program_id, &ID, data)?;
match instruction {
HelloInstruction::Hello => HelloAccounts::try_from(accounts)?.process(data),
}
}
}
}
Building for SBF
To compile the program for the Solana BPF target:
cargo build --release --target bpfel-unknown-none -p hello_solana -Z build-std -F bpf-entrypoint
The workspace .cargo/config.toml already sets the required linker flags for bpfel-unknown-none. The -Z build-std flag rebuilds core and alloc for the BPF target.
Writing tests
Tests run against the native Rust library (without bpf-entrypoint). You can verify discriminator values, instruction serialization, and program ID validity without needing a full Solana validator:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discriminator_hello_value() {
assert_eq!(HelloInstruction::Hello as u8, 0);
}
#[test]
fn discriminator_roundtrip() {
let parsed = HelloInstruction::try_from(0u8);
assert!(parsed.is_ok());
}
#[test]
fn discriminator_invalid_byte_fails() {
let result = HelloInstruction::try_from(99u8);
assert!(result.is_err());
}
#[test]
fn instruction_data_has_discriminator() {
assert!(HelloInstructionData::matches_discriminator(&[0u8]));
assert!(!HelloInstructionData::matches_discriminator(&[1u8]));
}
#[test]
fn program_id_is_valid() {
assert_ne!(ID, Address::default());
}
}
}
For full integration tests that simulate the Solana runtime, add mollusk-svm as a dev-dependency and use its transaction builder to invoke your program’s process_instruction function.
Next steps
- Add on-chain state with
#[account]– see thecounter_programexample. - Handle multiple instructions by adding more variants to your discriminator enum.
- Add PDA-based accounts with
create_program_account_with_bump. - Follow the Token Escrow Tutorial for a real-world program with token transfers and CPI.
Token CPI Recipes
This page collects the token-program CPI patterns that changed or became more important with the Pinocchio 0.11 upgrade:
- token
Batch - token
UnwrapLamports - token
WithdrawExcessLamports - token-2022
Reallocate
All examples assume the token feature is enabled in your program crate:
pina = { version = "...", features = ["derive", "logs", "token"] }
Before you invoke token CPIs
Keep the same runtime rules explicit in Pina:
- validate the token program account explicitly when it is passed in
- call
assert_writable()on every account your instruction expects to mutate - call
assert_signer()on every authority that must authorize the CPI - validate ATA addresses explicitly when the CPI expects a specific associated token account
- if you loaded account state with
.as_account()or.as_account_mut(), copy out the fields you need and drop the guard before the CPI
That last point matters more now that as_account() and as_account_mut() return borrow guards instead of bare references.
Token Batch
pina::token::instructions::Batch lets you serialize multiple SPL token instructions into one token-program batch CPI. This is useful when you already know the full instruction set up front and want one token-program invocation instead of several separate calls.
#![allow(unused)]
fn main() {
use core::mem::MaybeUninit;
use pina::InstructionAccount;
use pina::ProgramResult;
use pina::pinocchio::cpi::CpiAccount;
use pina::token::instructions::Batch;
use pina::token::instructions::InitializeAccount3;
use pina::token::instructions::InitializeMint2;
use pina::token::instructions::IntoBatch;
fn initialize_mint_and_vault(
mint: &pina::AccountView,
vault: &pina::AccountView,
mint_authority: &pina::AccountView,
vault_owner: &pina::AccountView,
) -> ProgramResult {
mint.assert_writable()?;
vault.assert_writable()?;
mint_authority.assert_signer()?;
let mut data = [MaybeUninit::<u8>::uninit(); Batch::MAX_DATA_LEN];
let mut instruction_accounts =
[MaybeUninit::<InstructionAccount>::uninit(); Batch::MAX_ACCOUNTS_LEN];
let mut accounts = [MaybeUninit::<CpiAccount>::uninit(); Batch::MAX_ACCOUNTS_LEN];
let mut batch = Batch::new(&mut data, &mut instruction_accounts, &mut accounts)?;
InitializeMint2::new(
mint,
9,
mint_authority.address(),
Some(mint_authority.address()),
)
.into_batch(&mut batch)?;
InitializeAccount3::new(vault, mint, vault_owner.address()).into_batch(&mut batch)?;
batch.invoke()
}
}
Use Batch when:
- all instructions target the same token program
- you can prepare all required buffers up front
- you want the token program, not your program, to interpret the batched payload
Token UnwrapLamports
UnwrapLamports transfers lamports out of a wrapped-native token account. Use Amount::All to unwrap everything or Amount::Some(amount) for a partial unwrap.
#![allow(unused)]
fn main() {
use pina::ProgramResult;
use pina::token::instructions::Amount;
use pina::token::instructions::UnwrapLamports;
fn unwrap_all_native_sol(
source: &pina::AccountView,
destination: &pina::AccountView,
authority: &pina::AccountView,
) -> ProgramResult {
source.assert_writable()?;
destination.assert_writable()?;
authority.assert_signer()?;
UnwrapLamports::new(source, destination, authority, Amount::All).invoke()
}
}
This is the right helper when the source account is a wrapped SOL token account and you want to move lamports back out to a system account.
Token WithdrawExcessLamports
WithdrawExcessLamports is the “rescue stray SOL” helper. It moves lamports that were sent to a token-owned account by mistake while leaving the required rent-exempt balance behind.
#![allow(unused)]
fn main() {
use pina::ProgramResult;
use pina::token::instructions::WithdrawExcessLamports;
fn rescue_stray_sol(
source: &pina::AccountView,
destination: &pina::AccountView,
authority: &pina::AccountView,
) -> ProgramResult {
source.assert_writable()?;
destination.assert_writable()?;
authority.assert_signer()?;
WithdrawExcessLamports::new(source, destination, authority).invoke()
}
}
Reach for this when the source account is still a token-program-owned account and you want to keep it valid instead of closing it.
Token-2022 Reallocate
Reallocate grows a token-2022 account so it can hold additional extension state. The payer funds the extra rent, the system program is passed explicitly, and the owner/delegate still authorizes the change.
#![allow(unused)]
fn main() {
use pina::ProgramResult;
use pina::system;
use pina::token_2022;
use pina::token_2022::instructions::ExtensionDiscriminator;
use pina::token_2022::instructions::Reallocate;
fn enable_token_extensions(
account: &pina::AccountView,
payer: &pina::AccountView,
system_program: &pina::AccountView,
owner: &pina::AccountView,
) -> ProgramResult {
account.assert_writable()?;
payer.assert_signer()?.assert_writable()?;
system_program.assert_address(&system::ID)?;
owner.assert_signer()?;
let extensions = [
ExtensionDiscriminator::MemoTransfer,
ExtensionDiscriminator::TransferHook,
];
Reallocate::new(
&token_2022::ID,
account,
payer,
system_program,
owner,
&extensions,
)
.invoke()
}
}
Use this before initializing or relying on token-2022 extensions that require additional account space.
Practical migration notes
When porting older code to the current Pina API, keep these patterns in mind:
&mut [AccountView]entrypoints do not make writability checks implicit- mutable account fields in
#[derive(Accounts)]help the type system and IDL, butassert_writable()should still appear in the runtime validation chain - borrow guards should stay short-lived around token CPIs
- token and token-2022 state loaders still work through
pina::token::stateandpina::token_2022::state, including theTokenAccountcompatibility alias
For a larger end-to-end token flow, see the token-escrow tutorial.
Token Escrow Tutorial
This tutorial walks through the examples/escrow_program step by step. The program implements a trustless token exchange between two parties using a PDA-owned vault account.
How the escrow works
- Make – the maker deposits token A into a PDA-owned vault and records the desired amount of token B in an escrow state account.
- Take – the taker sends token B to the maker, the vault releases token A to the taker, and the escrow is closed with rent returned to the maker.
No party needs to trust the other. The program enforces the exchange atomically: either both transfers happen or neither does.
Project setup
The escrow program enables the token feature for SPL token helpers:
[dependencies]
pina = { workspace = true, features = ["logs", "token", "derive"] }
[dev-dependencies]
mollusk-svm = { workspace = true }
The token feature unlocks CPI wrappers for SPL Token, Token-2022, and Associated Token Account operations.
Program ID and discriminators
#![allow(unused)]
fn main() {
use pina::*;
declare_id!("4ibrEMW5F6hKnkW4jVedswYv6H6VtwPN6ar6dvXDN1nT");
#[discriminator]
pub enum EscrowInstruction {
Make = 1,
Take = 2,
}
#[discriminator]
pub enum EscrowAccount {
EscrowState = 1,
}
}
Two discriminator enums serve different purposes. EscrowInstruction tags instruction data so the entrypoint can dispatch to the right handler. EscrowAccount tags on-chain account data so the program can verify it is reading the correct account type.
Custom errors
The #[error] macro converts an enum into a set of ProgramError::Custom error codes:
#![allow(unused)]
fn main() {
#[error]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EscrowError {
OfferKeyMismatch = 0,
TokenAccountMismatch = 1,
}
}
Each variant’s numeric value becomes the custom error code. You can return these from any processor via Err(EscrowError::OfferKeyMismatch.into()).
Escrow state account
The #[account] macro defines the on-chain state layout:
#![allow(unused)]
fn main() {
#[account(discriminator = EscrowAccount)]
pub struct EscrowState {
pub maker: Address,
pub mint_a: Address,
pub mint_b: Address,
pub amount_a: PodU64,
pub amount_b: PodU64,
pub seed: PodU64,
pub bump: u8,
}
}
The macro auto-injects a discriminator field as the first byte (set to EscrowAccount::EscrowState). It also derives Pod, Zeroable, HasDiscriminator, and TypedBuilder. All fields use fixed-size types (Address is 32 bytes, PodU64 is 8 bytes little-endian) so the struct has a stable #[repr(C)] layout suitable for zero-copy reads.
The seed and bump fields are stored so that PDA derivation can be verified on subsequent instructions without re-computing it.
Instruction data
#![allow(unused)]
fn main() {
#[instruction(discriminator = EscrowInstruction, variant = Make)]
pub struct MakeInstruction {
pub seed: PodU64,
pub amount_a: PodU64,
pub amount_b: PodU64,
pub bump: u8,
}
#[instruction(discriminator = EscrowInstruction, variant = Take)]
pub struct TakeInstruction {}
}
MakeInstruction carries all the parameters needed to set up the escrow. TakeInstruction has no payload beyond its discriminator byte – the taker just needs to invoke the instruction with the right accounts.
PDA seeds
The escrow PDA is derived from a prefix, the maker’s address, and a user-chosen seed:
#![allow(unused)]
fn main() {
const SEED_PREFIX: &[u8] = b"escrow";
macro_rules! seeds_escrow {
($maker:expr, $seed:expr) => {
&[SEED_PREFIX, $maker, $seed]
};
($maker:expr, $seed:expr, $bump:expr) => {
&[SEED_PREFIX, $maker, $seed, &[$bump]]
};
}
}
The seed macro generates the PDA seeds array in both forms: without bump (for create_program_account_with_bump) and with bump (for assert_seeds_with_bump).
Make: accounts and validation
#![allow(unused)]
fn main() {
#[derive(Accounts, Debug)]
pub struct MakeAccounts<'a> {
pub maker: &'a AccountView,
pub mint_a: &'a AccountView,
pub mint_b: &'a AccountView,
pub maker_ata_a: &'a AccountView,
pub escrow: &'a mut AccountView,
pub vault: &'a AccountView,
pub system_program: &'a AccountView,
pub token_program: &'a AccountView,
}
}
Accounts are listed in the order clients must provide them. The #[derive(Accounts)] macro maps each positional AccountView from the mutable entrypoint slice into its named field.
The processor validates every account before performing any mutation:
#![allow(unused)]
fn main() {
const SPL_PROGRAM_IDS: [Address; 2] = [token::ID, token_2022::ID];
impl<'a> ProcessAccountInfos<'a> for MakeAccounts<'a> {
fn process(self, data: &[u8]) -> ProgramResult {
let args = MakeInstruction::try_from_bytes(data)?;
let maker_address = *self.maker.address();
let escrow_seeds = seeds_escrow!(maker_address.as_ref(), &args.seed.0);
let escrow_seeds_with_bump = seeds_escrow!(maker_address.as_ref(), &args.seed.0, args.bump);
// Validate all accounts before mutating anything.
self.token_program.assert_addresses(&SPL_PROGRAM_IDS)?;
self.maker.assert_signer()?;
self.mint_a.assert_owners(&SPL_PROGRAM_IDS)?;
self.mint_b.assert_owners(&SPL_PROGRAM_IDS)?;
self.maker_ata_a.assert_associated_token_address(
self.maker.address(),
self.mint_a.address(),
self.token_program.address(),
)?;
self.escrow
.assert_empty()?
.assert_writable()?
.assert_seeds_with_bump(escrow_seeds_with_bump, &ID)?;
self.vault
.assert_empty()?
.assert_writable()?
.assert_associated_token_address(
self.escrow.address(),
self.mint_a.address(),
self.token_program.address(),
)?;
// ... create accounts and transfer tokens ...
Ok(())
}
}
}
Key validation patterns:
assert_addresseschecks that the token program is either SPL Token or Token-2022.assert_signerensures the maker signed the transaction.assert_ownersverifies mint accounts are owned by a token program.assert_associated_token_addressderives the expected ATA address and compares.assert_empty+assert_writable+assert_seeds_with_bumpvalidates the PDA is fresh and derivable.
Validation methods return the same reference type they receive, so mutable chains stay mutable all the way to as_account_mut().
Make: creating the escrow
After validation the processor creates the PDA account and initializes its state:
#![allow(unused)]
fn main() {
create_program_account_with_bump::<EscrowState>(
self.escrow,
self.maker,
&ID,
escrow_seeds,
args.bump,
)?;
let mut escrow = self.escrow.as_account_mut::<EscrowState>(&ID)?;
*escrow = EscrowState::builder()
.maker(*self.maker.address())
.mint_a(*self.mint_a.address())
.mint_b(*self.mint_b.address())
.amount_a(args.amount_a)
.amount_b(args.amount_b)
.seed(args.seed)
.bump(args.bump)
.build();
drop(escrow);
}
create_program_account_with_bump issues a CreateAccount CPI to the system program, allocating size_of::<EscrowState>() bytes and setting the owner to this program.
as_account_mut reinterprets the raw account bytes as a guard-backed RefMut<EscrowState>. The builder (generated by the #[account] macro) provides a type-safe way to populate all fields.
Make: token operations via CPI
With the escrow account created, the program creates the vault ATA and transfers tokens:
#![allow(unused)]
fn main() {
associated_token_account::instructions::Create {
account: self.vault,
funding_account: self.maker,
wallet: self.escrow,
mint: self.mint_a,
system_program: self.system_program,
token_program: self.token_program,
}
.invoke()?;
let decimals = self.mint_a.as_token_mint()?.decimals();
token_2022::instructions::TransferChecked {
from: self.maker_ata_a,
to: self.vault,
authority: self.maker,
amount: args.amount_a.into(),
mint: self.mint_a,
decimals,
token_program: self.token_program.address(),
}
.invoke()?;
}
Pina’s token feature provides typed CPI instruction builders. You fill in the struct fields and call .invoke() – the framework handles account meta construction and the CPI call.
The vault is an ATA owned by the escrow PDA. This means only the escrow program (signing with the PDA seeds) can later release the tokens.
Take: completing the exchange
The Take instruction performs two token transfers and cleans up:
- Transfer token B from taker to maker (authorized by the taker’s signature).
- Transfer token A from vault to taker (authorized by the escrow PDA via
invoke_signed). - Close the vault account and return rent to the maker.
- Zero and close the escrow state account.
#![allow(unused)]
fn main() {
impl<'a> ProcessAccountInfos<'a> for TakeAccounts<'a> {
fn process(self, data: &[u8]) -> ProgramResult {
let _ = TakeInstruction::try_from_bytes(data)?;
// ... validation omitted for brevity ...
let (maker, seed, bump, amount_b) = {
let escrow = self.escrow.as_account::<EscrowState>(&ID)?;
(escrow.maker, escrow.seed, escrow.bump, escrow.amount_b)
};
// Transfer token B: taker -> maker
token_2022::instructions::TransferChecked {
from: self.taker_ata_b,
mint: self.mint_b,
to: self.maker_ata_b,
authority: self.taker,
amount: u64::from(amount_b),
decimals: self.mint_b.as_token_2022_mint()?.decimals(),
token_program: self.token_program.address(),
}
.invoke()?;
// Transfer token A: vault -> taker (PDA-signed)
let bump_as_seeds = [bump];
let escrow_seeds =
seeds_escrow!(true, self.maker.address().as_ref(), &seed.0, &bump_as_seeds);
let escrow_signer = Signer::from(&escrow_seeds);
let signers = [escrow_signer];
token_2022::instructions::TransferChecked {
from: self.vault,
mint: self.mint_a,
to: self.taker_ata_a,
authority: self.escrow,
amount: self.vault.as_token_2022_account()?.amount(),
decimals: self.mint_a.as_token_2022_mint()?.decimals(),
token_program: self.token_program.address(),
}
.invoke_signed(&signers)?;
// Close vault and escrow
token_2022::instructions::CloseAccount {
account: self.vault,
destination: self.maker,
authority: self.escrow,
token_program: self.token_program.address(),
}
.invoke_signed(&signers)?;
self.escrow.as_account_mut::<EscrowState>(&ID)?.zeroed();
self.escrow.close_with_recipient(self.maker)
}
}
}
The PDA signer is constructed from the same seeds used to derive the escrow address. invoke_signed passes these seeds to the runtime so it can verify the PDA signature.
close_with_recipient transfers the remaining lamports to the maker and closes the account. Use zeroed() first when the account data must be wiped before close.
Entrypoint
The entrypoint ties everything together with a simple match:
#![allow(unused)]
fn main() {
#[cfg(feature = "bpf-entrypoint")]
pub mod entrypoint {
use pina::*;
use super::*;
nostd_entrypoint!(process_instruction);
#[inline(always)]
pub fn process_instruction(
program_id: &Address,
accounts: &mut [AccountView],
data: &[u8],
) -> ProgramResult {
let instruction: EscrowInstruction = parse_instruction(program_id, &ID, data)?;
match instruction {
EscrowInstruction::Make => MakeAccounts::try_from(accounts)?.process(data),
EscrowInstruction::Take => TakeAccounts::try_from(accounts)?.process(data),
}
}
}
}
Testing
Unit tests verify discriminator stability, seed construction, and program ID validation:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instruction_discriminators_are_stable() {
assert_eq!(EscrowInstruction::Make as u8, 1);
assert_eq!(EscrowInstruction::Take as u8, 2);
}
#[test]
fn seeds_macro_builds_expected_seed_arrays() {
let maker = [3u8; 32];
let seed = PodU64::from_primitive(42);
let bump = 7u8;
let seeds = seeds_escrow!(&maker, &seed.0);
assert_eq!(seeds.len(), 3);
let seeds_with_bump = seeds_escrow!(&maker, &seed.0, bump);
assert_eq!(seeds_with_bump.len(), 4);
}
#[test]
fn parse_instruction_rejects_program_id_mismatch() {
let wrong_program_id: Address = [9u8; 32].into();
let data = [EscrowInstruction::Make as u8];
let result = parse_instruction::<EscrowInstruction>(&wrong_program_id, &ID, &data);
assert!(matches!(result, Err(ProgramError::IncorrectProgramId)));
}
}
}
For full integration tests, use mollusk-svm to simulate transactions with real token accounts and verify the entire Make/Take flow end-to-end.
Key takeaways
- PDA vaults hold tokens on behalf of the program. Only the program can sign for them using
invoke_signed. - Validation-first – check every account before performing any mutation.
- Typed CPI builders in the
tokenfeature eliminate raw account-meta boilerplate. - Zero-copy state with
#[account]avoids serialization overhead. - Feature-gated entrypoints let the same crate serve as both an on-chain program and a testable library.
Migrating from Anchor
This guide maps common Anchor patterns to their Pina equivalents. If you have an existing Anchor program and want to rewrite it with Pina for lower compute usage and smaller binaries, this is the reference to follow.
The repository includes several anchor_* example programs that demonstrate direct parity with Anchor’s own test suite. These are referenced throughout this guide.
Program structure
Anchor
#![allow(unused)]
fn main() {
use anchor_lang::prelude::*;
declare_id!("Fg6PaFpoGXk...");
#[program]
pub mod my_program {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
// ...
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(init, payer = user, space = 8 + MyAccount::INIT_SPACE)]
pub my_account: Account<'info, MyAccount>,
pub system_program: Program<'info, System>,
}
}
Pina
#![allow(unused)]
fn main() {
use pina::*;
declare_id!("Fg6PaFpoGXk...");
#[discriminator]
pub enum MyInstruction {
Initialize = 0,
}
#[instruction(discriminator = MyInstruction, variant = Initialize)]
pub struct InitializeInstruction {}
#[derive(Accounts, Debug)]
pub struct InitializeAccounts<'a> {
pub user: &'a AccountView,
pub my_account: &'a mut AccountView,
pub system_program: &'a AccountView,
}
impl<'a> ProcessAccountInfos<'a> for InitializeAccounts<'a> {
fn process(self, data: &[u8]) -> ProgramResult {
let _ = InitializeInstruction::try_from_bytes(data)?;
self.user.assert_signer()?.assert_writable()?;
self.my_account.assert_empty()?.assert_writable()?;
self.system_program.assert_address(&system::ID)?;
// ...
Ok(())
}
}
}
Key differences:
- No
#[program]module. Pina uses explicit discriminator enums and a manualmatchin the entrypoint. - No
Context<T>. The entrypoint receives&mut [AccountView],#[derive(Accounts)]maps that mutable slice into typed fields, and the processor receives rawdata: &[u8]. - Constraints are code, not attributes. Validation happens inside
processvia chained assertions rather than#[account(...)]attribute directives.
Account constraints to validation chains
Anchor expresses constraints as attributes on account fields. Pina uses explicit method calls on AccountView references.
| Anchor attribute | Pina equivalent |
|---|---|
Signer<'info> | account.assert_signer()? |
#[account(mut)] | account.assert_writable()? |
#[account(owner = program)] | account.assert_owner(&program_id)? |
#[account(address = KEY)] | account.assert_address(&KEY)? |
#[account(seeds = [...], bump)] | account.assert_seeds_with_bump(seeds, &ID)? |
#[account(init, ...)] | account.assert_empty()? then create_program_account_with_bump(...) |
#[account(constraint = expr)] | Write the check directly in process and return an error |
Account<'info, T> (type check) | account.assert_type::<T>(&owner)? |
Pina’s assertion methods return the same reference type they receive, so shared chains stay shared and mutable chains stay mutable:
#![allow(unused)]
fn main() {
self.counter
.assert_not_empty()?
.assert_writable()?
.assert_type::<CounterState>(&ID)?;
}
See examples/counter_program for a complete PDA creation and validation example, and examples/anchor_duplicate_mutable_accounts for explicit duplicate-account safety checks.
Account data: Borsh to Pod
Anchor (Borsh)
#![allow(unused)]
fn main() {
#[account]
pub struct MyAccount {
pub authority: Pubkey,
pub value: u64,
pub active: bool,
}
}
Anchor uses Borsh serialization by default. The #[account] macro adds an 8-byte discriminator (SHA-256 hash prefix) and derives BorshSerialize/BorshDeserialize.
Pina (Pod / zero-copy)
#![allow(unused)]
fn main() {
#[account(discriminator = MyAccountType)]
pub struct MyAccount {
pub authority: Address,
pub value: PodU64,
pub active: PodBool,
}
}
Pina uses zero-copy (bytemuck::Pod) layouts. Every field must be a fixed-size, Copy type. This means:
| Anchor type | Pina type | Notes |
|---|---|---|
Pubkey | Address | Both are [u8; 32] |
u64 | PodU64 | Little-endian, alignment-safe |
u32 | PodU32 | Little-endian, alignment-safe |
u16 | PodU16 | Little-endian, alignment-safe |
i64 | PodI64 | Little-endian, alignment-safe |
bool | PodBool | Single byte |
String | [u8; N] | Fixed-size byte arrays only |
Vec<T> | Not supported | Use fixed-size arrays |
Option<T> | Manual encoding | Use a sentinel value or a PodBool flag |
Pod wrappers are needed because #[repr(C)] structs require all fields to have alignment 1 for bytemuck compatibility. Converting to and from native types:
#![allow(unused)]
fn main() {
// Creating Pod values
let value = PodU64::from_primitive(42);
let active = PodBool::from(true);
// Reading Pod values
let n: u64 = value.into();
let b: bool = active.into();
}
The #[account] macro’s discriminator is a single u8 (or configurable width) rather than Anchor’s 8-byte hash. This saves 7 bytes per account.
Discriminators
Anchor
Anchor generates 8-byte discriminators from sha256("account:<StructName>") or sha256("global:<method_name>"). These are implicit – you never write them manually.
Pina
Pina uses explicit discriminator enums with numeric values:
#![allow(unused)]
fn main() {
#[discriminator]
pub enum MyInstruction {
Initialize = 0,
Update = 1,
}
#[discriminator]
pub enum MyAccountType {
MyAccount = 1,
}
}
Each #[instruction] or #[account] macro references its discriminator enum and variant:
#![allow(unused)]
fn main() {
#[instruction(discriminator = MyInstruction, variant = Initialize)]
pub struct InitializeInstruction {
// ...
}
#[account(discriminator = MyAccountType)]
pub struct MyAccount {
// ...
}
}
Benefits of explicit discriminators:
- Stable, human-readable values (not hash-dependent).
- Single byte by default (configurable to u16/u32/u64), saving space.
- No hidden behavior – you control the exact values.
Migration from fixed 8-byte prefixes (Anchor-compatible data)
If you are coming from Anchor/Borsh with implicit 8-byte discriminators, there are two practical migration paths:
1) Keep old on-chain layouts and add compatibility readers
Use a lightweight adapter struct for legacy decoding, then convert into a pinned Pina struct in memory. This is useful when you cannot migrate all existing accounts immediately.
#![allow(unused)]
fn main() {
#[repr(C)]
pub struct LegacyAccountV0 {
discriminator: [u8; 8],
owner: [u8; 32],
value: PodU64,
}
#[discriminator]
pub enum MyAccountType {
MyAccountV0 = 0,
MyAccount = 1,
}
impl LegacyAccountV0 {
pub fn into_live(self) -> Result<MyAccount, ProgramError> {
if self.discriminator != LEGACY_ACCOUNT_DISCRIMINATOR {
return Err(ProgramError::InvalidAccountData);
}
Ok(MyAccount {
discriminator: [MyAccountType::MyAccount as u8],
owner: self.owner,
value: self.value,
})
}
}
}
2) Migrate state in place (recommended for production)
For long-lived accounts, add a migration instruction that rewrites every stored account from the legacy header to the new first-field discriminator layout. This gives you one canonical on-chain schema thereafter.
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.
Discriminator and payload versioning
| Change | Compatibility impact |
|---|---|
| Add a new enum variant | Usually backward-compatible if old clients ignore unknown variants |
| Change an existing variant value | Breaking for every historical byte slice |
| Reorder or remove struct fields | Breaking (offsets change) |
| Append fields to a struct | Mostly non-breaking, but consumers must accept the larger size |
Switch primitive width (u8 → u16, etc.) | Breaking for serialized payloads at that boundary |
For on-chain accounts, treat layout as part of protocol ABI:
- Keep field order stable.
- Introduce optional
versionfields at the tail for in-place migration strategies. - Never change existing discriminator values in place.
- When incompatible layout changes are required, perform explicit migration with a new account version and an operator upgrade flow.
For instruction payloads:
- Prefer additive migration: add a new variant and keep legacy handlers for a release cycle.
- Reject stale payload shapes with explicit errors rather than silently reinterpreting bytes.
Errors
Anchor
#![allow(unused)]
fn main() {
#[error_code]
pub enum MyError {
#[msg("Value is too large")]
ValueTooLarge,
}
}
Anchor assigns error codes starting at 6000 and provides #[msg] for error messages.
Pina
#![allow(unused)]
fn main() {
#[error]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MyError {
ValueTooLarge = 6000,
}
}
Pina’s #[error] macro generates From<MyError> for ProgramError using ProgramError::Custom(code). You choose the numeric code explicitly. To return an error:
#![allow(unused)]
fn main() {
return Err(MyError::ValueTooLarge.into());
}
See examples/anchor_errors for a complete parity port of Anchor’s error handling, including guard helpers like require_eq and require_gt.
Events
Anchor
#![allow(unused)]
fn main() {
#[event]
pub struct MyEvent {
pub data: u64,
pub label: String,
}
emit!(MyEvent {
data: 5,
label: "hello".into()
});
}
Pina
#![allow(unused)]
fn main() {
#[discriminator]
pub enum EventDiscriminator {
MyEvent = 1,
}
#[event(discriminator = EventDiscriminator)]
#[derive(Debug)]
pub struct MyEvent {
pub data: PodU64,
pub label: [u8; 8],
}
}
Pina events are Pod structs with explicit discriminators, just like accounts and instructions. They do not have a built-in emit! macro – event emission is handled by writing bytes to the transaction log or via CPI patterns. The #[event] macro gives you HasDiscriminator, Pod, Zeroable, and TypedBuilder.
See examples/anchor_events for the full parity port.
CPI (Cross-Program Invocation)
Anchor
#![allow(unused)]
fn main() {
let cpi_accounts = Transfer {
from: ctx.accounts.from.to_account_info(),
to: ctx.accounts.to.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
let cpi_ctx = CpiContext::new(ctx.accounts.token_program.to_account_info(), cpi_accounts);
token::transfer(cpi_ctx, amount)?;
}
Pina
#![allow(unused)]
fn main() {
token_2022::instructions::TransferChecked {
from: self.from,
to: self.to,
authority: self.authority,
amount,
mint: self.mint,
decimals,
token_program: self.token_program.address(),
}
.invoke()?;
}
Pina’s CPI helpers (enabled with features = ["token"]) are typed instruction builders. Fill in the struct and call .invoke() or .invoke_signed(&signers) for PDA-authorized calls. No CpiContext wrapper is needed.
See examples/escrow_program for CPI usage with both token transfers and ATA creation.
Account creation
Anchor
#![allow(unused)]
fn main() {
#[account(init, payer = user, space = 8 + 32 + 8)]
pub my_account: Account<'info, MyData>,
}
Pina
#![allow(unused)]
fn main() {
// For PDA accounts:
create_program_account_with_bump::<MyData>(
self.my_account,
self.payer,
&ID,
seeds,
bump,
)?;
// For regular accounts:
create_account(
self.payer,
self.my_account,
size_of::<MyData>(),
&ID,
)?;
}
Space is automatically computed from size_of::<MyData>() for the PDA helper. For create_account you pass the size explicitly. In both cases, rent-exemption lamports are calculated and transferred automatically.
no_std and the entrypoint
Anchor programs use #[program] which generates the entrypoint. Pina programs are #![no_std] and use a feature-gated entrypoint module:
#![allow(unused)]
#![no_std]
fn main() {
#[cfg(feature = "bpf-entrypoint")]
pub mod entrypoint {
use pina::*;
use super::*;
nostd_entrypoint!(process_instruction);
#[inline(always)]
pub fn process_instruction(
program_id: &Address,
accounts: &mut [AccountView],
data: &[u8],
) -> ProgramResult {
let instruction: MyInstruction = parse_instruction(program_id, &ID, data)?;
match instruction {
MyInstruction::Initialize => InitializeAccounts::try_from(accounts)?.process(data),
}
}
}
}
The feature gate means tests compile without BPF entrypoint overhead. The nostd_entrypoint! macro wires up the BPF program entrypoint, a minimal panic handler, and a no-allocation stub.
Testing
Anchor
Anchor programs are typically tested with TypeScript/Mocha tests that run against a local validator via anchor test.
Pina
Pina programs are tested as regular Rust libraries:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discriminator_roundtrip() {
assert!(MyInstruction::try_from(0u8).is_ok());
assert!(MyInstruction::try_from(99u8).is_err());
}
}
}
For integration tests, use mollusk-svm (a Solana SVM simulator) instead of a full validator:
[dev-dependencies]
mollusk-svm = { workspace = true }
This gives you fast, deterministic tests without network I/O.
Migration checklist
- Replace
anchor_lang::prelude::*withuse pina::*. - Convert
#[account]structs from Borsh to Pod types (PodU64,PodBool,Address, fixed-size arrays). - Define explicit
#[discriminator]enums for instructions and accounts. - Replace
#[account(...)]constraint attributes with validation chain calls inprocess. - Replace
Context<T>with#[derive(Accounts)]structs andProcessAccountInfos. - Replace
CpiContextpatterns with Pina’s typed CPI instruction builders. - Replace
#[error_code]with#[error]and explicit numeric codes. - Replace
#[event]+emit!with Pina’s Pod-based event structs. - Add
#![no_std]and thebpf-entrypointfeature gate. - Port TypeScript tests to Rust using
mollusk-svmor native unit tests.
Anchor Test Porting
This page tracks sequential parity ports from solana-foundation/anchor/tests into examples/, using Rust-first tests (mollusk/native unit tests) instead of TypeScript.
Port Status
-
anchor-cli-account(no direct parity yet; Anchor CLI account decoding over dynamicVec/Stringdata is not a direct pina/no-std match) -
anchor-cli-idl(no direct parity yet; Anchor CLI IDL account lifecycle is Anchor-CLI-specific) -
auction-house -
bench->examples/prop_amm_program(adapted frombench/programs/prop-amm/anchor-v2; asm fast path intentionally not ported) -
bpf-upgradeable-state -
cashiers-check -
cfo -
chat -
composite -
cpi-returns -
custom-coder -
custom-discriminator -
custom-program -
declare-id->examples/anchor_declare_id -
declare-program->examples/anchor_declare_program(adapted) -
duplicate-mutable-accounts->examples/anchor_duplicate_mutable_accounts(adapted) -
errors->examples/anchor_errors(adapted) -
escrow->examples/escrow_program(adapted with parity-focused tests) -
events->examples/anchor_events(adapted event schema parity) -
floats->examples/anchor_floats -
idl -
ido-pool -
interface-account -
lazy-account -
lockup -
misc -
multiple-suites -
multiple-suites-run-single -
multisig -
optional -
pda-derivation -
pyth -
realloc->examples/anchor_realloc(adapted) -
relations-derivation -
safety-checks -
spl -
swap -
system-accounts->examples/anchor_system_accounts(adapted) -
sysvars->examples/anchor_sysvars(adapted) -
test-instruction-validation -
tictactoe -
typescript -
validator-clone -
zero-copy
Anchor lang-v2 review and follow-up backlog
This page records the focused review of solana-foundation/anchor on branch anchor-next, with emphasis on lang-v2 and the bench/programs/prop-amm benchmark.
Reviewed upstream paths
Primary framework/runtime files:
lang-v2/README.mdlang-v2/src/context.rslang-v2/src/context_cpi.rslang-v2/src/cursor.rslang-v2/src/dispatch.rslang-v2/src/lib.rslang-v2/src/loader.rslang-v2/src/pod.rslang-v2/src/traits.rs
Benchmark/example files:
bench/programs/prop-amm/anchor-v1/src/lib.rsbench/programs/prop-amm/anchor-v2/src/lib.rsbench/programs/prop-amm/anchor-v2/src/instructions.rsbench/programs/prop-amm/anchor-v2/src/instructions/initialize.rsbench/programs/prop-amm/anchor-v2/src/instructions/rotate_authority.rsbench/programs/prop-amm/anchor-v2/src/error.rsbench/programs/prop-amm/anchor-v2/src/state.rsbench/programs/prop-amm/anchor-v2/src/asm/entrypoint.s
What Pina should adopt, adapt, and avoid
Adopt or adapt
- A more trait-first runtime surface instead of pushing more semantics into proc macros.
- Typed CPI handles and typed CPI account structs.
- Cursor-based account parsing that can support nested account groups, duplicate-account tracking, and explicit remaining-account handling.
- Better generated-client account resolution for default programs and derived addresses.
- Carefully bounded zero-copy container ideas where they do not violate Pina’s fixed-layout and no-allocation goals.
Explicitly avoid
- Handwritten asm fast paths like
bench/programs/prop-amm/anchor-v2/src/asm/entrypoint.s. - Any design that compiles away important safety checks in production configurations.
- Heap-backed runtime APIs in core on-chain paths.
- Dynamic container abstractions that undermine discriminator-first fixed layouts or make borrow provenance harder to reason about.
Prioritized issue backlog
P0 — Add typed CPI handles and a const-generic CPI context (#142)
Why this matters:
lang-v2/src/traits.rsandlang-v2/src/context_cpi.rsshow the clearest ergonomic win relative to current Pina APIs.- Typed CPI handles help encode writable/read-only intent and reduce accidental misuse when building CPI account lists.
- This fits naturally with Pina’s recent move to guard-backed account loaders.
Scope for Pina:
- Keep the API allocator-free.
- Prefer const-generic account counts over
Vecin on-chain paths. - Start from checked
pinocchio::cpi::invoke_signedand only consider unchecked variants after the account-runtime story is stronger. - Eventually teach generated CPI account structs and client code to use the same model.
Do not copy directly from Anchor:
- The heap-backed
Vecdesign inlang-v2/src/context_cpi.rs. - Any panic-based writable checks.
P1 — Rework #[derive(Accounts)] around a cursor-based loader/runtime (#143)
See also: Accounts cursor runtime draft.
Why this matters:
lang-v2/src/cursor.rs,lang-v2/src/loader.rs, andlang-v2/src/dispatch.rsexpose a stronger runtime model than today’s simple slice destructuring.- Pina currently gets good clarity from explicit validation code, but it has limited structure for nested account groups, richer duplicate-account analysis, and future optional-account ergonomics.
Scope for Pina:
- Preserve explicit validation chains as the user-facing model.
- Move parsing/runtime logic out of ad-hoc generated slice destructuring into a reusable cursor abstraction.
- Make duplicate mutable alias checks and remaining-account handling first-class.
- Keep the final API
no_stdand allocator-free.
Do not copy directly from Anchor:
- Any runtime path that assumes heap allocation is acceptable.
- Any abstraction that hides validation order or weakens Pina’s explicitness.
P1 — Improve generated clients with resolved default accounts and PDA inference (#144)
Why this matters:
- Anchor
lang-v2pushes more account-resolution intelligence into generated surfaces. - Pina’s generated Codama clients are already useful, but callers still supply more boilerplate than necessary for common program/system account defaults and canonical PDA derivations.
Scope for Pina:
- Auto-fill well-known program accounts when the IDL marks them as defaults.
- Support deterministic PDA derivation helpers in generated clients.
- Make signer/writable expectations clearer in generated builders.
- Preserve exact IDL semantics so generation stays reproducible and reviewable.
P2 — Add compile-time PDA bump precomputation where seeds are fully static
Why this matters:
- Anchor
lang-v2/README.mdhints at compile-time bump optimization opportunities. - Pina already favors explicit seeds and deterministic PDA behavior, so compile-time precomputation could shave host-side and on-chain setup overhead in narrow cases.
Scope for Pina:
- Restrict this to obviously static seed sets.
- Preserve canonical bump semantics.
- Keep it as an optimization, not a semantic fork in PDA validation.
P2 — Explore bounded dynamic Pod containers only if they fit Pina’s invariants
Why this matters:
lang-v2/src/pod.rsis an interesting demonstration of alignment-safe integer wrappers and zero-copy-friendly support code.- More expressive bounded containers could unlock richer examples without abandoning fixed-layout account models.
Scope for Pina:
- Keep discriminator-first layouts.
- Avoid allocator requirements.
- Prefer small, fixed-capacity, bytemuck-auditable designs.
- Land only after stronger tests, docs, and invariants are written down.
prop_amm port outcome
The Pina port lives at:
examples/prop_amm_program/Cargo.tomlexamples/prop_amm_program/src/lib.rsexamples/prop_amm_program/tests/e2e.rs
This is intentionally a semantic port of the benchmark logic, not a port of the handwritten assembly benchmark harness.
Accounts cursor runtime design draft
- Status: Implemented foundation
- Related issue: #143
- Related review: Anchor
lang-v2review
This document sketches a concrete path for reworking Pina’s #[derive(Accounts)] runtime around a cursor-based loader, informed by solana-foundation/anchor anchor-next/lang-v2 but adapted to Pina’s explicit-validation and no_std constraints.
Why change the current model?
Today #[derive(Accounts)] mainly expands to direct slice destructuring over &mut [AccountView].
That is simple and easy to audit, but it starts to strain when Pina needs richer account-loading behavior such as:
- nested account groups
- explicit remaining-account cursors
- duplicate mutable alias diagnostics
- future optional/composite account ergonomics
- shared loader logic between entrypoint dispatch and future CPI/codegen paths
Anchor lang-v2 demonstrates that a reusable cursor/loader runtime can support these needs cleanly.
Constraints Pina must preserve
Any redesign must keep the following Pina invariants intact:
- no heap allocation in core on-chain account parsing
no_stdcompatibility- explicit validation order in user code
- discriminator-first fixed layouts
- no weakening of duplicate mutable alias guarantees
- proc-macro output should remain understandable and reviewable
Proposed runtime model
1. Introduce an AccountsCursor<'a>
A lightweight cursor owns:
- the original
&'a mut [AccountView] - the current index
- duplicate-account bookkeeping state
Core operations:
peek()next()remaining()finish_exact()or equivalent exactness check
The cursor should be allocator-free and work entirely with indices and borrowed references.
2. Split parsing from validation
Derive-generated code should stop directly destructuring account slices.
Instead it should:
- parse structural account positions through the cursor
- produce a typed accounts struct of borrowed
&AccountView/&mut AccountView - leave semantic validation in user-authored
process(...)methods
This keeps Pina’s existing style intact:
- parsing answers “which account is where?”
- user code answers “is this account valid for my program logic?”
3. Make duplicate mutable checks first-class in the runtime
The cursor should explicitly track whether a parsed writable account aliases a prior writable account reference.
That allows:
- earlier and clearer failures
- shared duplicate-account logic across all derived structs
- future nested-account support without re-implementing alias checks in generated code paths
4. Model remaining accounts as a cursor view, not only a trailing slice
Current #[pina(remaining)] gives raw trailing access.
The next step should preserve that capability but add a more structured runtime concept:
- either a
RemainingAccounts<'a>wrapper - or a borrow of the cursor with restricted operations
That would make future nested parsing and optional account loading safer and more composable.
Suggested traits
A possible shape is:
pub struct AccountsCursor<'a> {
// borrowed account slice, index, duplicate tracking
}
pub trait ParseAccounts<'a>: Sized {
fn parse_accounts(cursor: &mut AccountsCursor<'a>) -> Result<Self, ProgramError>;
}
Then #[derive(Accounts)] generates ParseAccounts and keeps TryFromAccountInfos as a compatibility layer that delegates to the cursor runtime.
Nested account groups use the same trait: a non-reference field in a derived accounts struct is parsed with <FieldTy as ParseAccounts>::parse_accounts(cursor). That means parent and child loaders share one cursor, preserve left-to-right account order, and run one exactness check only at the outer TryFromAccountInfos boundary. Raw trailing accounts still use #[pina(remaining)], which consumes the cursor tail and intentionally leaves those accounts for user code to inspect.
That gives Pina an incremental migration path:
- existing public trait stays usable
- internal runtime becomes richer
- follow-up derive features have a stable foundation
Migration plan
Phase 1 — Internal cursor introduction
- add
AccountsCursor<'a>behind the current public API - reimplement
TryFromAccountInfosderive output in terms of the cursor - preserve current exact/remaining behavior byte-for-byte where possible
Phase 2 — Explicit duplicate-account runtime checks
- move duplicate mutable alias detection into cursor state
- add adversarial regression tests for nested and repeated-account cases
Phase 3 — Structured remaining accounts
- add a typed remaining-accounts wrapper
- update derive code and docs
- preserve a simple slice-based escape hatch when needed
Phase 4 — Nested/composite account loaders
- allow derive-generated account structs to contain parsed sub-groups
- keep final semantic validation explicit in user code
Testing requirements
This redesign should land with dedicated tests for:
- exact account count handling
- remaining-account passthrough
- duplicate mutable alias rejection
- nested loader ordering
- compatibility with current
ProcessAccountInfosflows - compile-fail coverage for unsupported account-struct shapes
Non-goals
This draft does not propose:
- hiding validation logic behind Anchor-style constraint attributes
- adding allocator-backed parsing helpers to on-chain paths
- making
#[derive(Accounts)]opaque or difficult to inspect - changing the guard-backed typed account loader model
Relationship to the CPI-handle prototype
The cursor-runtime work complements the typed CPI-handle prototype from #142.
Together they point toward a more coherent runtime story:
- cursor-based typed account parsing on entry
- guard-backed typed data access in handlers
- typed CPI-handle composition for outbound invocations
That combination is the main architectural lesson Pina can take from Anchor lang-v2 without copying its heap-backed or asm-oriented design choices.
Security Model
Pina’s safety posture is built around explicit validation and predictable state transitions.
Core invariants
- Type correctness: account bytes must match expected discriminator and layout.
- Authority correctness: signer/owner checks must precede mutation.
- PDA correctness: seed and bump checks must gate PDA-bound operations.
- Value correctness: arithmetic and balance mutations must be checked.
See ADR 0001, ADR 0002, and ADR 0003 for the durable rationale behind these invariants.
Version-safe binary layout and compatibility
The discriminator-first model makes byte layout part of protocol compatibility. Treat every #[account] struct as ABI:
- Do not reorder fields.
- Do not change existing discriminator values.
- Do not alter field types in-place without migration.
- If a struct grows, treat it as a new versioned shape and migrate state explicitly.
Discriminator and payload versioning
| Change | Compatibility impact |
|---|---|
| Add a new enum variant | Usually backward-compatible if old clients ignore unknown variants |
| Change an existing variant value | Breaking for every historical byte slice |
| Reorder or remove struct fields | Breaking (offsets change) |
| Append fields to a struct | Mostly non-breaking, but consumers must accept the larger size |
Switch primitive width (u8 → u16, etc.) | Breaking for serialized payloads at that boundary |
For on-chain accounts, treat layout as part of protocol ABI:
- Keep field order stable.
- Introduce optional
versionfields at the tail for in-place migration strategies. - Never change existing discriminator values in place.
- When incompatible layout changes are required, perform explicit migration with a new account version and an operator upgrade flow.
For instruction payloads:
- Prefer additive migration: add a new variant and keep legacy handlers for a release cycle.
- Reject stale payload shapes with explicit errors rather than silently reinterpreting bytes.
High-priority guardrails
- Prefer checked arithmetic (
checked_add,checked_sub) for all user-facing or balance-affecting values. - Ensure all token account types used by helper traits implement
AccountValidation. - Keep close/transfer helpers conservation-safe (no temporary double-crediting).
Closing accounts safely
Closing guidance under Pinocchio 0.11:
close_with_recipient()transfers lamports and closes the account handle, but it does not zero or resize account data for you.- When stale bytes must be invalidated, use
close_account_zeroed()or manually callzeroed()beforeclose_with_recipient(). - The
account-resizefeature only affects realloc helpers; it does not change close semantics.
Best practices
- Always call
assert_signer()before trusting authority accounts - Always call
assert_owner()/assert_owners()beforeas_token_*()methods - Always call
assert_empty()before account initialization to prevent reinitialization attacks - Always verify program accounts with
assert_address()/assert_program()before CPI invocations - Use
assert_type::<T>()to prevent type cosplay — it checks discriminator, owner, and data size - Use
close_account_zeroed()orzeroed()+close_with_recipient()when stale account bytes must be invalidated before close - Prefer
assert_seeds()/assert_canonical_bump()overassert_seeds_with_bump()to enforce canonical PDA bumps - Namespace PDA seeds with type-specific prefixes to prevent PDA sharing across account types
Testing strategy
- Unit tests for negative validation cases.
- Regression tests for every previously fixed bug class.
- Integration tests for cross-account invariants where mutation order matters.
Development Workflow
Daily loop
devenv shell
cargo build --all-features
cargo test
lint:all
verify:docs
verify:security
test:idl
Formatting and linting
- Rust and markdown formatting are enforced through
dprint. - Clippy runs with strict workspace lint settings, and
lint:clippyalso checks the custom Dylint crates underlints/.
Reusable documentation blocks
- Template providers live in
templates/*.t.md. - Prefer updating the shared provider block first when the same guidance appears in the README, crate readmes, and mdBook.
- Run
docs:syncafter changing provider blocks to refresh all consumer blocks. - Run
docs:check(orverify:docs) in CI to ensure docs stay synchronized.
Dependency/tooling updates
update:deps
Codama/IDL workflow
# Generate Codama IDLs for all examples.
codama:idl:all
# Generate Rust + JS clients.
codama:clients:generate
# Generate IDLs + Rust/JS clients in one command.
pina codama generate
# Run the complete Codama pipeline.
codama:test
# Run IDL fixture drift + validation checks used by CI.
test:idl
# Run Quasar SVM generated-client e2e checks alongside LiteSVM.
pnpm run test:quasar-svm
Dependency security
security:denyruns policy checks (license allow-list, source restrictions, dependency bans).security:auditruns RustSec vulnerability checks overCargo.lock.verify:securityruns both checks.
Coverage
Generate coverage locally for pina and pina_cli:
coverage:all
This produces an LCOV report at target/coverage/lcov.info.
For experimental Solana-VM coverage collection (non-blocking), run:
coverage:vm:experimental
Changesets
Any code changes in crates/ or examples/ should include a file in .changeset/ describing impact and release type.
CI and Releases
CI jobs
The GitHub CI workflow verifies:
lint:clippylint:formatverify:docsverify:securitytest:all(cargo test --all-features --locked)feature-matrixforpinaacross explicit configurations:default(build:pina:default+test:pina:default)no-default(build:pina:no-default-only+test:pina:no-default+doc:pina:no-default)token-only(build:pina:token-only+test:pina:token-only)all-features(build:pina:all-features+test:pina:all-features)
test:program-e2e(Example program tests, SBF builds, mollusk-svm integration tests, and BPF artifact verification)test:idl(regeneratecodama/idls,codama/clients/rust,codama/clients/js, validate outputs, and fail on any diff)cargo build --lockedcargo build --all-features --locked
Separate PR workflows also verify:
binary-sizefor SBF artifact size reportingcompute-unitsfor tracked static CU regression reporting vs the PR base revision
This keeps code quality, behavior, documentation build health, feature-flag compatibility, and performance visibility aligned.
Compute-unit regression policy
The compute-units workflow builds tracked SBF example programs on both the PR head and the PR base, runs pina profile --json on each .so, and compares the resulting static total_cu estimates.
Tracked programs are defined in scripts/compute-unit-policy.json:
hello_solanaanchor_duplicate_mutable_accountsanchor_eventsanchor_sysvarsanchor_system_accountsanchor_realloc
Current policy:
- warn when
total_cuincreases by at least+250CU and+5.0% - fail when
total_cuincreases by at least+500CU and+10.0% - decreases and smaller increases are informational
Notes:
- this workflow intentionally uses static SBF estimates from
pina profile, not runtime validator traces - the tradeoff is deliberate: static profiling is deterministic and stable for PR-vs-base comparison
- the tracked set should favor example programs that build reliably on both the PR head and the PR base with the gallery linker used in CI; richer CPI-heavy and token-heavy flows remain covered by the main
ciand program E2E jobs - if the tracked set or thresholds need to change, update
scripts/compute-unit-policy.json
Local reproduction:
profile:cu:tracked
report:cu:compare:main
The comparison writes artifacts to target/cu/, including a markdown summary and a machine-readable JSON report.
Coverage
The coverage workflow runs focused coverage with cargo llvm-cov and publishes an LCOV artifact:
- Command:
coverage:all - Artifact:
target/coverage/lcov.info - Optional upload: Codecov (
fail_ci_if_error: false)
Docs publishing
The docs-pages workflow publishes the mdBook to GitHub Pages:
- Trigger: pushes to
mainthat touch docs + GitHub Releasepublished - Build command:
docs:build(output indocs/book) - Deploy target: GitHub Pages (
https://pina-rs.github.io/pina/)
CLI asset releases
The assets workflow only publishes binaries for CLI tags:
- Required tag format:
pina_cli/v<version> - Tag/version check: release tag must match
crates/pina_cli/Cargo.toml - Build scope:
crates/pina_clionly (package = "pina_cli")
Release workflow
Use knope for changelog/release management:
knope document-change
knope release
knope publish
Keep changeset descriptions explicit and user-impact focused.
Review Follow-ups
This project now tracks and resolves relevant previously ignored pull-request feedback where it still applies to the current codebase.
Addressed items
- Enabled
solana-addresscurve25519feature to ensure PDA helper APIs are available in host builds. - Replaced unchecked
current + 1increment in the counter example with checked arithmetic andProgramError::ArithmeticOverflowon failure. - Fixed stale hello example docs that described behavior not present in code.
- Added missing
AccountValidationimplementations for all token account/mint types used by token conversion helpers.
Explicitly ignored as not relevant
Some unresolved comments pointed to paths that no longer exist in the current repository (for example removed historical security/ and lints/ paths). These were not applied because there is no active code location to patch.
Recommendations
This section contains concrete suggestions to better align the codebase with Pina’s goals.
1. Add performance regression baselines
Goal alignment: low compute units.
- Add benchmark harnesses for high-volume instruction paths (counter increment, escrow state transitions, token flows).
- Track baseline CU budgets in CI and fail when regressions exceed threshold.
- Keep benchmark inputs deterministic and versioned.
2. Strengthen feature-matrix testing
Goal alignment: no_std reliability + maintainability.
- Test a matrix of feature combinations (
default,--no-default-features,--features token,--all-features). - Include
bpfel-unknown-nonebuild checks for all example programs. - Add one CI lane for docs/tests under minimal features to catch accidental default-feature coupling.
3. Expand security regression coverage
Goal alignment: safety.
- Add explicit regression tests for arithmetic overflow/underflow paths.
- Add tests for token transfer edge cases (insufficient funds, overflow on destination).
- Add tests for each account close/transfer helper to verify lamport conservation invariants.
4. Improve macro diagnostics quality
Goal alignment: developer experience.
- Add compile-fail tests for malformed macro attributes and unsupported discriminator configurations.
- Improve error messages to include expected/actual forms and actionable fix text.
- Maintain a docs page mapping macro attributes to generated behaviors.
5. Keep architecture decision records current
Goal alignment: maintainability.
- Link architecture-impacting pull requests to the ADR they follow or update.
- Add a new ADR when a public invariant, compatibility contract, or verification policy changes materially.
- Periodically review older ADRs and mark them superseded when the project direction changes.
6. Publish a migration guide from Anchor-style patterns
Goal alignment: adoption.
- Document direct mapping from common Anchor concepts to Pina equivalents.
- Provide before/after examples for account validation and instruction routing.
- Include expected CU/dependency differences for realistic workloads.