Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Pina

The Pina logo: a low-poly origami pineapple

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.
  • A complete CLI reference, including automation-safe output contracts.
  • Installation and operating guidance for the packaged Pina agent skill.
  • 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.
  • A production-readiness gate for asset-bearing programs.
  • CI/release pipeline expectations.

Project Goals

Pina’s codebase currently optimizes for the following goals.

1. Performance and low compute units

  • Prefer pinocchio primitives 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

Build a program with the CLI

To write your own program you need the pina CLI and the external tools it drives, not this repository:

  • rustup: the scaffold’s rust-toolchain.toml selects the pinned nightly.
  • The Agave CLI, which provides cargo-build-sbf for pina build and solana for pina deploy.
  • Node.js with npx when you generate TypeScript or Dart clients.
  • The Surfpool CLI for pina dev; pina test embeds Surfpool’s SDK in the generated test package.
npm install --global @pina-rs/cli
pina init my_program
cd my_program
pina doctor               # checks every prerequisite above
pina keys new             # replace the shared placeholder program ID
pina migrations create --auto true  # track every contract; record the version-0 ABI baseline
pina build
pina test --unit
pina test
pina generate

Run pina keys new and pina migrations create --auto true before anything else. The migration policy lives in the manifest that command writes, not in pina.toml, so until it runs nothing is tracked and generated clients carry no version envelope; the baseline also records the program ID it belongs to. See the pina init reference for what the scaffold contains.

Work on Pina itself

Prerequisites

  • Rust nightly toolchain from rust-toolchain.toml
  • devenv (Nix-based environment)
  • gh (for GitHub workflows)

Setup

devenv shell
install:all

See pina init --help for options like --path and --force. The CLI reference documents every command, output contract, and automation workflow.

For agent-assisted project work, install the Pina skill.

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

For a deterministic Docker build suitable for Solana’s verified-build workflow, install solana-verify 0.5.1, start Docker, commit the complete source tree, and run:

pina build --verify

This produces the canonical deploy artifact plus a hash-bound Pina build record. It does not perform on-chain verification. See pina build for the trust model, prerequisites, and limitations.

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.

Before adapting an example for a program that controls assets, work through the Production Readiness gate. Examples demonstrate scoped framework behavior; they are not audited deployment templates.

Build this documentation

docs:build

The generated site is written to docs/book/.

Pina CLI

The pina command scaffolds programs, extracts Codama IDLs, renders clients, reads bundled reference material, profiles compiled SBF binaries, and plans explicit deployments. It is designed for both interactive use and scripted or agent-driven discovery.

Install

Install the prebuilt npm package. It selects the native binary for the current operating system, CPU, and Linux C library:

npm install --global @pina-rs/cli
pina --version

Or install from crates.io with Rust:

cargo install pina_cli
pina --version

Inside this repository, enter the development shell and use the pina shortcut:

devenv shell
pina --help

The shortcut runs cargo run -p pina_cli -- ... against the checked-out source.

Command map

CommandPurposePrimary output
pina initCreate a project-aware program scaffoldFiles plus next steps
pina lintRun the official security lints via the lint driverCompiler diagnostics and optional fixes
pina locksReport write-lock hotspots and conflicting instructionsText or JSON
pina mapChart instructions, account locks, and conflictsSelf-contained HTML or JSON
pina buildBuild SBF, optionally with deterministic verification inputsSBF, IDL, and optional build-record files
pina verifyCompare deployments and record verified sourceStatus or transaction
pina generateGenerate configured client ecosystemsGenerated clients
pina cpiGenerate a standalone Pina CPI crate from an IDLRust crate
pina importImport a foreign program’s IDL as a CPI crateRust crate with provenance
pina testRun native/Mollusk or SBF/Surfpool testsTest runner output
pina devStart an offline Surfpool watch/redeploy loopSurfpool UI and logs
pina idlExtract a Codama root-node IDLJSON
pina docsList or render bundled terminal docsTerminal text
pina keysInspect or explicitly change program identityText or JSON
pina doctorDiagnose project and toolchain readinessText or JSON
pina explainExplain which account check failed in a transactionText or JSON
pina completionsGenerate a shell completion scriptShell script
pina profileEstimate SBF compute cost, or trace it per line from testsText, JSON, folded stacks, or HTML
pina rehearseReplay real traffic against an upgrade before shipping itText or JSON
pina deployPlan and execute an explicit cluster deploymentPlan or JSON
pina generateGenerate IDLs and Rust, CPI, JavaScript, Dart, and CLI clientsGenerated directories

Discover the interface

The help tree is intentionally self-describing:

pina --help
pina build --help
pina verify --help
pina verify check --help
pina verify record --help
pina verify submit --help
pina verify status --help
pina generate --help
pina cpi --help
pina idl --help
pina docs --help
pina init --help
pina lint --help
pina locks --help
pina map --help
pina keys --help
pina doctor --help
pina explain --help
pina completions --help
pina profile --help
pina rehearse --help
pina deploy --help

Long help includes the input contract, output behavior, defaults, and copyable examples. Run pina docs with no topic to discover the bundled architecture references.

Streams and exit codes

Commandstdoutstderr
idlJSON when --output is omittedProgress, extraction counts, errors
docsTopic index or rendered MarkdownErrors
initCreated path and next stepsErrors
lintCompletion summaryCargo progress and lint diagnostics
locksLock report or JSONErrors and denied hotspots
mapWritten HTML path, or JSONProgress and errors
buildPublished artifact summaryCargo output and errors
verify checkMatching hashMismatch hashes and errors
verify recordUpstream streamed progressUpstream diagnostics and errors
generateIDL and client summaryRenderer output and errors
cpiGenerated crate summaryConversion and renderer errors
testChild test-runner outputBuild output and errors
devSurfpool UI and logsBuild output and errors
keysIdentity report or change summaryErrors
doctorDiagnostic reportErrors
explainExplanation reportErrors
completionsCompletion scriptErrors
profileReport when --output is omittedErrors; trace adds build and test output and warnings
rehearseRehearsal report (text or JSON)Progress and errors
deployPlan, rehearsal, and completionConfirmation, progress, errors
codama generateCompletion summaryErrors and renderer failures

Successful commands exit with code 0. Operational failures exit with code 1. Completed comparisons that find a difference exit with code 2: verification hash mismatches, profile compare regressions, and rehearse behaviour changes. rehearse exits with code 3 when it could compare no transaction. deploy --rehearse stops before sending anything with the same codes: 2 for behaviour changes, and 3 when nothing was compared or the program is not deployed yet. Invalid command-line syntax is rejected by Clap with a non-zero usage error before an operation begins.

For reliable automation, capture stdout only when the command documents it as machine-readable. See Automation and Agent Usage for a compact discovery protocol.

Path behavior

Relative paths are resolved from the process working directory. Project-aware commands discover the nearest pina.toml or unambiguous Cargo package and use Cargo metadata for the library source and target directory. Output commands create their documented output directories where applicable, but pina idl --output expects the parent directory to exist. Identity replacement requires pina keys new --force; profile reports are published atomically and cannot alias the input binary.

Environment

Project-aware commands read pina.toml and respect standard Cargo variables such as CARGO_TARGET_DIR and CARGO. The CLI also reads these Pina-specific optional environment variables:

VariableUsed byMeaning
PINA_TEMPLATES_DIRpina docsDirectory containing custom <topic>.t.md files
PINA_SURFPOOLpina dev, pina rehearseSurfpool executable to run instead of surfpool

No configuration file is required for an unambiguous Cargo package. pina init creates a small pina.toml so every tool and agent discovers the same program and client choices.

Agents that maintain Pina projects can install the companion @pina-rs/skill package.

pina init

Create a standalone Pina program scaffold.

Synopsis

pina init [OPTIONS] <NAME>
InputDefaultMeaning
NAMErequiredRust package name. 1-64 ASCII letters, numbers, -, and _, starting with a letter or _, and not a Rust keyword.
-p, --path <DIR>./<name>Destination directory.
--forceoffOverwrite scaffold-owned files that already exist.

Example

pina init counter_program
pina init counter_program --path ./programs/counter_program

The command creates:

counter_program/
├── .cargo/
│   └── config.toml
├── src/
│   ├── entrypoint.rs
│   └── lib.rs
├── tests/
│   ├── integration.rs
│   └── surfpool/
│       ├── src/
│       │   └── lib.rs
│       └── Cargo.toml
├── .gitignore
├── build.rs
├── Cargo.toml
├── pina.toml
├── README.md
└── rust-toolchain.toml

The scaffold includes:

  • a no_std program library and feature-gated SBF entrypoint;
  • a typed instruction discriminator and starter instruction;
  • an Accounts struct with signer validation;
  • a cargo build-program alias for the Agave cargo build-sbf driver;
  • a pinned nightly Rust toolchain with the rust-src and clippy components, matching the nightly Pina publishes prebuilt lint drivers for;
  • the build.rs rerun directive, but no migration manifest yet, because the history binds to the program address and the migration policy lives only in that manifest;
  • no source-installed lint tooling; pina lint resolves a prebuilt pina_lint_driver for the project’s active toolchain, and pina lint --build-driver compiles one when no prebuilt driver matches;
  • host-side discriminator and program-ID smoke tests;
  • a pina dependency with the account-resize, logs, and derive features (no Mollusk; add it when you need VM-level unit tests);
  • a dedicated host-only test package with one pina_test dependency for the isolated Surfpool test;
  • project-local discovery and client-generation settings in pina.toml.

Every scaffold starts with the same non-system placeholder address, which nobody holds the keypair for, so it can be neither deployed nor safely snapshotted; pina doctor warns while it is in place. Run pina keys new to give the program its own identity, then pina migrations create --auto true to track every contract and record the version-0 baseline. Until that run, nothing is tracked: the program builds and its clients generate without a version envelope, so record the baseline before you generate clients you intend to keep.

Versions are counted per contract, so the default u8 width gives each account, instruction, and event 255 versions. Add --version-type u16 (or u32) to that first run only if one contract may need more: the width can change while nothing is published, and freezes at the first deployment.

SBF builds use the Agave CLI’s cargo-build-sbf, so install the Agave CLI before the first pina build. Client generation for TypeScript and Dart needs Node.js with npx.

The scaffold declares no [workspace]. When the destination is inside an existing Cargo workspace, pina init prints the enclosing manifest: add the program to that workspace’s members, and declare the dependencies generated Rust clients inherit in its [workspace.dependencies].

Existing destinations

Without --force, Pina checks every scaffold-owned destination before writing anything. If one already exists, the command exits without modifying the scaffold.

--force overwrites only the known scaffold files listed above. It does not delete unrelated files in the destination directory, but it does rewrite src/lib.rs, including resetting declare_id! to the placeholder.

Next steps

The command prints the next steps for the generated package:

cd ./counter_program
pina keys new
pina migrations create --auto true  # track every contract; record version 0
pina lint
pina build
pina test --unit
pina test
pina dev --yes
pina generate

pina keys new writes the program keypair to target/deploy/<name>-keypair.json. The scaffold’s .gitignore excludes target/, and cargo clean deletes it, so back the keypair up before deploying.

Use pina init --help for the authoritative command-line surface.

See Project Configuration for every generated pina.toml field. The scaffold writes no [migrations] table: the policy belongs to the manifest pina migrations create writes.

pina lint

Run Pina’s official security lint set against the discovered program.

The lints live in the pina_lints crate, which is published to crates.io and statically compiled into the pina_lint_driver binary, a rustc wrapper. No external lint tooling is downloaded, no precompiled lint bundles exist, and the project itself never supplies or configures lint libraries.

Synopsis

pina lint [OPTIONS]
InputDefaultMeaning
-p, --project <DIR>.Directory inside the Pina or Cargo project to discover.
--fixoffApply machine-applicable suggestions, then rerun diagnostics.
--build-driveroffBuild the lint driver with the active toolchain.
--explain <LINT>—Print one lint’s reference and exit without linting.

Examples

pina lint
pina lint --fix
pina lint --project ./programs/counter
pina lint --explain require_zeroed_before_close
pina lint --build-driver

pina lint discovers the nearest pina.toml or unambiguous Cargo package. It checks only that program package and does not lint workspace dependencies.

Toolchain negotiation

A lint driver links the compiler’s unstable rustc_private crates, so a driver only loads against the exact compiler revision it was built with. Two dated nightlies that share a release line expose incompatible compiler libraries, and nothing can make a driver built for one load against another. Pina therefore resolves a driver for whatever toolchain the project activates, instead of requiring one pinned nightly.

pina lint reads the active toolchain from rustc -vV and tries, in order:

  1. PINA_LINT_DRIVER_PATH, the escape hatch for a driver you built yourself.
  2. A driver already cached for this CLI release and this exact compiler revision.
  3. The driver bundled next to the CLI, when it loads against the active toolchain.
  4. A download from the Pina release matching this CLI version.

Asking a candidate driver to start is the version check. A driver built for another compiler cannot load librustc_driver, so a driver that starts is by construction the right one. That keeps the negotiation honest — it tests the property that actually matters instead of trusting a version string — and it means a driver built for a different nightly is skipped rather than producing an opaque loader failure deep inside cargo.

The cache lives below the platform’s per-user cache directory ($XDG_CACHE_HOME or ~/.cache on Linux, ~/Library/Caches on macOS, %LOCALAPPDATA% on Windows) under pina/lint-driver/<pina-version>/<host>-<commit-hash>/. Both the host triple and the full compiler commit hash are part of the path, so two nightlies installed side by side each keep their own driver and a cached driver is never reused for a compiler it was not built with. Set PINA_LINT_CACHE_DIR to relocate it.

A download is addressed by name: pina-lint-driver-<host>-<commit-hash>. Because a release builds its driver with the nightly that release pins, a project on any other nightly asks for a name the release does not publish and gets a clear miss. That is the correct outcome — no release can publish a driver for every nightly — and the CLI reports it with the remedy rather than handing cargo a binary it cannot load.

Building the driver

pina lint --build-driver

Use this on a nightly Pina publishes no prebuilt driver for. The build compiles the pina_lints release matching this CLI version, so the driver always runs exactly the lint set the CLI ships, and it compiles with your active toolchain. It requires the rustc-dev and rust-src components:

rustup component add rustc-dev rust-src

Cargo installs the driver into a staging root, the CLI copies it into the cache, and the staging root is removed. Later runs resolve the cached driver without invoking cargo, so the build cost is paid once per toolchain.

Diagnosing driver resolution

pina doctor
pina doctor --json

pina lint failing to find a driver is the one failure a user cannot debug from the message alone, so pina doctor reports the whole state: the active toolchain, the expected release, the resolved driver and how it was obtained, both search paths, and the one-line remedy. It reports without downloading, because a diagnostic that populates a cache cannot be run to find out what is wrong.

Every pina lint success line also names the driver that ran — (bundled), (cached), (downloaded), (built from source), or (PINA_LINT_DRIVER_PATH) — so a surprising result is traceable to the binary that produced it.

How lints run

pina lint runs cargo check — or cargo fix with --fix — with the resolved driver as RUSTC_WORKSPACE_WRAPPER. Cargo calls the driver with the arguments it would have passed to rustc; the driver registers every lint statically linked into it, and compilation continues normally with the lints emitted as ordinary compiler diagnostics. If RUSTC_WRAPPER already selects a compiler cache such as sccache, Cargo preserves it as the outer wrapper.

The CLI prepends the active sysroot’s library directory to the dynamic-library search path — DYLD_LIBRARY_PATH and LD_LIBRARY_PATH on macOS, LD_LIBRARY_PATH on other Unix, PATH on Windows — for the lint run, so the driver loads regardless of how the toolchain was installed. CARGO_TARGET_DIR continues to control normal project build artifacts.

To run a driver you built yourself — typically the workspace driver while developing a lint — set PINA_LINT_DRIVER_PATH to an executable binary path and pina lint uses it without further checks. The repository’s own security:pina-lint task uses this variable to run the workspace-built driver.

Driver environment variables

The driver reads a few environment variables:

VariableMeaning
PINA_LINT_NO_DEPSSet to 1 to lint only the primary package, not dependencies.
PINA_LINT_LEVELSComma-separated lint=level (allow/warn/deny) overrides.
PINA_LINT_ONLYRestrict linting to a single named lint.
PINA_LINT_LISTPrint the lint catalog instead of compiling.

pina lint sets PINA_LINT_NO_DEPS and forwards PINA_LINT_LEVELS from the project’s [lints] table. PINA_LINT_NO_DEPS, PINA_LINT_LEVELS, and PINA_LINT_ONLY are recorded in dep-info, so changing them invalidates cargo’s cached check results.

Configuring lint levels

Lint levels are configured in the project’s pina.toml under the [lints] table. Each entry maps a lint name to allow, warn, or deny; lints that are not listed keep their built-in default level.

[lints]
deny_heap_allocations_in_onchain_instruction_handlers = "deny"
require_explicit_discriminators_and_seed_namespaces = "allow"

Unknown lint names are rejected with the list of known lints. Deny-level security lints should not be disabled at crate scope; when a finding is a false positive, scope an #[allow(...)] to the smallest item and document the invariant.

Every lint’s contract, rationale, and sanctioned blessing pattern is in the Lint Reference, and pina lint --explain <LINT> prints one entry without leaving the terminal. Start there rather than guessing at an #[allow]: each entry names the API or restructure that satisfies the lint.

Fix mode

pina lint --fix
git diff

With --fix, pina lint runs cargo fix instead of cargo check. Pina supplies --allow-dirty, --allow-staged, and --allow-no-vcs because requesting --fix is explicit permission to edit the current working tree, including a newly initialized project that has not entered version control yet. Only diagnostics carrying machine-applicable suggestions can be changed automatically; findings without a safe rewrite remain diagnostics. Always inspect and test the resulting diff.

Security boundary

The lint driver is native executable code, not a passive rule file: it links against the compiler’s unstable internals and runs with the same local permissions as the invoking user. Pina therefore ships the driver prebuilt next to the CLI from the same attested release pipeline, never loads lint libraries from project metadata, and starts a candidate driver once before the lint run so a toolchain mismatch fails fast with a clear error. Every downloaded driver comes from the same release and the same assets that carry the CLI itself, so it is covered by the release’s build provenance attestation.

Downloads only ever come from Pina’s GitHub release for this CLI version. A project’s manifest cannot redirect the driver source, and no project metadata selects a lint library, so linting an untrusted repository does not execute code that repository chose. PINA_LINT_DRIVER_BASE_URL, PINA_LINT_DRIVER_REPO, and PINA_LINT_DRIVER_RELEASE exist for the release pipeline and the CLI’s own tests; treat setting them as equivalent to installing a driver yourself.

PINA_LINT_DRIVER_PATH executes whatever binary it names, so point it only at a driver you built yourself. pina lint --build-driver compiles from the matching pina_lints release on crates.io with your own toolchain.

Exit behavior

The command exits successfully only when a driver resolves for the active toolchain, it loads, compilation finishes, and all enabled security lints succeed. Without PINA_LINT_DRIVER_PATH the run negotiates a driver and additionally fails when none can be obtained; a diagnostic at an error level, a compilation failure, or an invalid PINA_LINT_DRIVER_PATH produces a non-zero exit in every mode. When no driver matches, cargo never runs, so a toolchain problem cannot be mistaken for a lint failure. Child Cargo output stays attached to the terminal; Pina prints a short completion summary naming the driver it used only after success.

pina locks

Report which instructions can never run in parallel, and the accounts that make them wait.

pina locks [OPTIONS]
pina locks
pina locks --project ./programs/privacy_pool
pina locks --json
pina locks --deny-hotspots

Why write locks matter

Solana’s scheduler runs two transactions in parallel only when neither one write-locks an account the other one locks. Every account a transaction marks writable is locked for the whole transaction, and every other transaction that touches that account waits.

Most accounts are chosen per user, so their locks rarely collide. A PDA whose seeds are all constants is different: it has the same address in every transaction. Every instruction that writes it takes the same lock, so all of that instruction’s traffic across the cluster runs one transaction at a time, however many users send it.

Pina knows this before the program is deployed. The IDL extractor records which accounts each instruction writes and which of them are PDAs, and pina locks reads that record without building or running the program.

Address classes

Every instruction account is grouped into an account node and given one of three classes:

ClassAddressUnified across instructions
fixedOne address for every caller: a PDA whose seeds are all constants, or a known address such as a program or sysvar. The report includes the derived base58 address.Yes, by PDA name or by address
keyedA PDA with variable seeds. Two transactions lock the same account only when they pass the same seed values. The report lists the seed names and types.Yes, by PDA name
callerAny account the caller chooses.No: each instruction slot is its own node

A slot is a PDA when its processor validates or derives it as one, or loads it as a #[pda] account type, as Generate an IDL describes.

Reading the report

The text report starts with hotspots: fixed accounts that at least one instruction writes. Each lists its derived address and seeds, the instructions that write it, the instructions that only read it, and what that costs:

merkle_tree
  address  BLquaQVntisnQpUoLpzbcFGDN9eG9Qf12TZZS5KVLaLz
  seeds    "privacy-pool-tree"
  writers  initialize, deposit, transfer
  readers  withdraw
  Every `initialize`, `deposit`, and `transfer` in the cluster runs one at a time; `withdraw` waits for each one.

A program without hotspots says so.

Next comes the conflict matrix. Rows and columns are the instructions, numbered in declaration order. The diagonal tells you whether an instruction conflicts with other transactions of itself.

SymbolKindMeaning
●alwaysBoth lock the same fixed account and at least one writes it. They never run in parallel.
◐mayBoth lock the same keyed PDA, or a fixed account one of them may omit, and at least one writes it. They wait for each other only when the addresses match.
·nonePina can prove no shared write lock.

none is not a promise of parallelism. Accounts the caller chooses are never compared, because two transactions may or may not pass the same one, and nothing in the program decides that. A program with too many instructions for a readable matrix gets a per-instruction list instead.

To explore the same analysis interactively, with the accounts behind each conflict and each account’s derivation, run pina map.

Fixing a hotspot

A hotspot is a design decision, not a bug, but it is usually an accidental one. Common fixes:

  • Shard the account. Add a variable seed, such as a shard index or the user’s address, so writers spread over many accounts. A global counter becomes per-shard counters that a reader sums.
  • Split hot fields out. Move the fields every user writes into per-user or per-position accounts, and keep the global account for values that rarely change.
  • Make readers read-only. An instruction that only reads a fixed account should take it as a read-only account. Two readers never conflict; a slot declared &mut AccountView is writable even when the handler never writes it.
  • Keep the singleton and say so. An admin configuration written only by admin instructions is a fine hotspot. Accept it in pina.toml so CI stops flagging it.

Accepting a hotspot

List intentional hotspots by account name under [locks] in pina.toml:

[locks]
allow = ["program_config"]

Allowed hotspots are still reported, marked (allowed in pina.toml). Every name must be a current hotspot of the program: a name that matches none, from a typo or a hotspot that was since removed, fails the command with the list of hotspots it could have named, so the allow list cannot silently go stale.

CI gate

pina locks --deny-hotspots

--deny-hotspots prints the same report, then exits with code 1 when any hotspot is not allowed. Errors such as a missing project, unparseable source, or an unknown allow entry also exit with code 1 and print the error to stderr.

Agent JSON

pina locks --json > locks.json
jq -e '.hotspots | map(select(.allowed | not)) | length == 0' locks.json

JSON is the only stdout content. Schema version 1 is a camelCase document:

  • schemaVersion, program, and programId;
  • nodes: every account node with its id (pda:<name>, address:<base58>, or caller:<instruction>.<slot>), name, class (fixed, keyed, or caller), address (or null), pda (or null), and seeds, each {"kind": "constant", "hex", "text"} or {"kind": "variable", "name", "type"};
  • instructions: each instruction’s writes and reads, every entry naming its node, slot, signer, and optional flags;
  • conflicts: each conflicting pair as instructions (declaration order, the same name twice for a self-conflict), kind (always or may), and the nodes that decide it;
  • hotspots: each fixed account some instruction writes, with its node, name, writers, readers, and allowed.

pina map

Render an interactive map of a program’s instructions and the accounts they lock.

pina map [OPTIONS]
pina map
open "$(pina map)"
pina map --project ./programs/privacy_pool
pina map --output ./docs/program-map.html
pina map --json

pina map reads the program source, runs the same analysis as pina locks, and writes one self-contained HTML file. The page inlines its styles, script, and data and makes no network requests, so it opens from disk, attaches to a pull request, or ships with documentation as is.

The file goes to <target>/pina/map.html under the project’s Cargo target directory unless --output names another path; missing parent directories are created. stdout carries only the written path, so open "$(pina map)" (or xdg-open) opens it in one step. Progress goes to stderr.

What the page shows

The page is drawn as an interlocking chart, the table a railway signal box uses to show which levers lock each other. Here the levers are accounts and the rows are instructions.

  • Header: the program name, its program ID with a copy button, and counts of instructions, accounts, PDAs, and hotspots.
  • Hotspots: one plate per fixed account that some instruction writes, with the cost in one sentence, for example “Every deposit and transfer in the cluster runs one at a time”. Select a plate to open the account.
  • Lock chart: rows are instructions in declaration order and columns are the accounts shared between instructions, hotspots first, then other fixed accounts, then keyed PDAs. A filled square is a write, an outlined square a read, a blue dot a signer, and a dashed square an account the caller may omit. A column’s top band gives its class: red for a hotspot, ink for another fixed account, blue for a keyed PDA. The last column counts each instruction’s caller-chosen accounts, which are never shared.
  • Tracing: hover or focus a row and every row it conflicts with lights up, red when they can never run in parallel and amber hatching when they collide only for the same seeds; the accounts behind each conflict are outlined and everything else fades. Selecting a column lights up the rows that lock that account instead.
  • Detail panel: selecting an instruction shows its docs, the instructions it never runs alongside or may wait for (with the accounts responsible), every account slot with its flags, PDA, and declarative constraints, and its arguments. Selecting an account shows its class, its account type’s docs and fields, the hotspot cost, the PDA derivation from constant seeds and typed seed slots to the derived address, and the instructions that write and read it.

The chart is keyboard accessible: rows and columns are buttons, the arrow keys move between rows (up and down) and between columns (left and right), Home and End jump to the ends, and Enter selects. It follows the system’s light or dark preference, honors reduced motion, and on a narrow screen scrolls the chart inside its frame rather than the page.

Trust boundary

Doc comments and seed strings come from the program source. The page embeds its data as JSON in a <script type="application/json"> block with <, >, &, U+2028, and U+2029 escaped, so no string can end the block, and it renders every string as text, never as markup.

Agent JSON

pina map --json > map.json
jq -e '.instructionDetails | length > 0' map.json

--json prints the map’s data instead of writing HTML, and conflicts with --output. The document is the pina locks --json schema-version-1 document with two more top-level fields:

  • instructionDetails: each instruction’s name, docs, arguments (name, type, docs), and accounts in slot order, each with its slot, node, writable, signer, optional, pda, constraints, and docs;
  • accountTypes: each #[account] type’s name, the pda it declares (or null), docs, and fields.

Errors such as a missing project, unparseable source, or an unwritable output path exit with code 1; an invalid flag combination exits with code 2.

pina build

Build the current Pina program for SBF and refresh its Codama IDL.

Synopsis

pina build [OPTIONS]
InputDefaultMeaning
-p, --project <DIR>current directoryStart directory for project discovery.
--features <FEATURE>noneExtra Cargo features; repeat or separate by commas.
--no-default-featuresoffDisable the program’s default Cargo features.
--verifyoffBuild deterministically through Solana Verify.
--solana-verify <COMMAND>solana-verifySelect the exact 0.5.1 executable; requires --verify.

bpf-entrypoint is always enabled and deduplicated from explicit features.

SBF compilation delegates to the Agave CLI’s cargo-build-sbf driver, which owns the SBF toolchain (its own rustc and sbf-linker from platform-tools). The Agave CLI must therefore be installed and on PATH; a nightly toolchain with rust-src is no longer required.

Outputs

Pina uses Cargo metadata, including CARGO_TARGET_DIR, to derive stable output paths:

<cargo-target>/deploy/<library-name>.so
<cargo-target>/idl/<library-name>.json

The library target name is authoritative. It may differ from the package name when [lib].name is set.

Cargo output is streamed to the terminal. Pina stages both outputs first and atomically replaces each destination file. A project-scoped advisory lock prevents concurrent builds from interleaving different IDL and SBF versions. If the second replacement fails, Pina attempts to restore the previous IDL. A failed Cargo build does not publish either output; the two separate files do not form a crash-atomic filesystem transaction.

pina build
pina build --features logs --no-default-features
pina build --project ./programs/counter

Deterministic verified-build artifacts

pina build --verify switches the SBF compiler backend to solana-verify. It does not query a cluster, compare an on-chain program, upload verification metadata, or install any tools.

Prerequisites:

  • solana-verify must report exactly version 0.5.1.

  • Docker must be installed and running. Solana Verify owns Docker discovery and diagnostics.

  • The project must be in a completely clean Git worktree. Staged, unstaged, and untracked files all cause the command to fail.

  • The Cargo workspace root reported by Cargo metadata must contain a tracked Cargo.lock.

  • Pinocchio and modular Solana SDK workspaces should declare the Solana CLI version at the workspace root so Solana Verify can select a pinned build image:

    [workspace.metadata.cli]
    solana = "3.0.0"
    

Pina copies only files tracked in the Git index into a private snapshot. Ignored files—including .env, keypairs, node_modules, .devenv, and ordinary build output—are not mounted into the container. Tracked symbolic links are rejected instead of followed. Git submodules are not supported in this first version; vendor or replace those inputs with tracked workspace dependencies.

cargo install solana-verify --version 0.5.1 --locked
docker version
pina build --verify

The deterministic build publishes the same canonical deployment paths as an ordinary build:

<cargo-target>/deploy/<library-name>.so
<cargo-target>/idl/<library-name>.json

It also retains a content-addressed copy and a Pina-local build record:

<cargo-target>/pina/verifiable/<library-name>-<executable-hash>.so
<cargo-target>/pina/verifiable/<library-name>-<executable-hash>.json

The executable hash follows Solana Verify semantics: trailing zero padding is removed before SHA-256. The JSON records only facts Pina can compute: the exact Solana Verify version, library and relative workspace paths, Cargo features, default-feature selection, lockfile hash, Git revision, repository URL when it is credential-free HTTPS, and source-state diagnostics. It is not an official on-chain verification record. Consumers must recompute the executable hash before trusting it, and publication workflows must independently confirm that the recorded revision is reachable from the public remote.

Pina atomically replaces each destination file. The content-addressed artifact and build record are written before the canonical artifact and IDL. These separate files do not form a single crash-atomic filesystem transaction; a failed final publication can leave a valid, hash-bound content-addressed record without updating the canonical deploy path.

Feature selection is identical for both backends:

pina build --verify --features logs --no-default-features

Pina forwards only its validated feature selection to Solana Verify. It intentionally offers no raw Cargo, Docker, image, or shell-argument passthrough.

A verified build does not apply the size profile. Solana Verify rebuilds from the recorded Git revision, so the inputs that shape the artifact have to live in the committed source; an override supplied only through environment variables or CLI flags is invisible to anyone reproducing that revision, which would make the verified hash unreproducible. Declare the profile under [profile.release] in the workspace manifest — the layout pina init generates — so both backends compile identically. When a requested size profile would make the two artifacts differ, pina build --verify warns and names the missing setting.

Solana Verify 0.5.1 is supported on Linux and macOS when a compatible executable and Docker runtime are available. Native Windows and FreeBSD execution fail before source staging; use a supported Linux environment instead.

Project discovery uses the nearest ancestor pina.toml. Existing projects without that file fall back to Cargo metadata; an ambiguous workspace root fails with the candidate package names instead of guessing.

See Project Configuration for the complete pina.toml schema and path rules.

Manage ABI migrations

Migration support is opt-in. Add migrations to each account, instruction payload, or event that Pina must track:

#![allow(unused)]
fn main() {
#[account(discriminator = AccountType::Profile, migrations)]
pub struct Profile {
	pub authority: Address,
	pub score: u64,
}
}

Pina inserts the version after the existing discriminator. Version 0 is the first captured shape. The source code never declares a version number, and the version field is one u8 byte unless the manifest records a wider width; see Choose the version width.

Each kind uses the version differently. An account migrates on-chain, one adjacent transition at a time. An instruction with migrations has an older payload converted to the current layout before its handler runs. An event is versioned but never converted: the program emits only the current version, and generated clients decode each historical version with its own schema. How a migration flows compares the three.

Opt whole kinds in

A program that wants every contract versioned can opt in by kind instead of annotating each declaration:

pina migrations create --auto true # or --auto accounts,events,instructions

--auto accepts true (or all) for every kind, false (or none) to clear the policy, or a comma-separated list of accounts, events, and instructions; any other name, or a name listed twice, is rejected. The policy envelopes accounts and events. It records instructions as snapshots without an envelope, so covering them does not change a payload byte; see Instructions without an envelope.

pina migrations create records the policy as auto in migrations/manifest.json and snapshots every contract of the listed kinds. A later run without --auto keeps the recorded policy, and a first run without it records no policy. The manifest is the only home of the policy and the single source of truth for macros: pina.toml holds no copy, because a proc macro does not re-expand when an unrelated toml file changes and a second copy could disagree with the first. The retired [migrations].auto and [migrations].version_type keys fail every command that reads pina.toml with an error naming the flag that replaces them. A struct that is not yet snapshotted still fails the build with the existing “run pina migrations create” error, so the workflow is unchanged.

Because the policy lives in the manifest, flipping it re-expands every contract without a source edit. When a policy is recorded, create scaffolds a build.rs containing:

fn main() {
	println!("cargo:rerun-if-changed=migrations/manifest.json");
}

The scaffold is idempotent and never overwrites an existing hand-written build script; create prints the exact line to add instead, and pina migrations check fails until it is present.

Per-item migrations = false keeps one contract out of an auto policy. Removing the envelope from a contract the manifest already records is an error rather than a silent opt-out: stripping an envelope is a wire-format change, so the build fails with the contract identity and the required remedy, and create refuses it with EnvelopeRemoval. The one exception is an unpublished instruction, which create may turn back into a snapshot. Dropping an enveloped kind with --auto is rejected the same way. A hand edit of the manifest’s auto cannot strip an envelope either: a declaration without a migrations token that the recorded policy no longer covers, but that the manifest records with an envelope, fails the build. Enabling auto on an already-launched program inserts an envelope into every account and event of the listed kinds — one recorded history entry per contract through create, behind --envelope-ack — while a new program simply captures that baseline.

Instructions without an envelope

An instruction covered by auto without the migrations token keeps its [discriminator][payload] wire format. The manifest records exactly one version of it with "envelope": false:

{
	"contracts": {
		"instruction:1:00": {
			"rustName": "InitializeInstruction",
			"envelope": false,
			"versions": [
				{
					"schema": {
						"layout": "fixed",
						"fields": [{ "name": "bump", "rustType": "u8" }]
					},
					"process": {
						"accounts": [
							{
								"name": "authority",
								"writable": true,
								"signer": true
							}
						]
					}
				}
			]
		}
	}
}

A process slot records only what sets it apart: writable, signer, and optional are written only when true, and defaultValue and pda only when set. A reader treats an absent field as false or none.

The snapshot stops wire-breaking changes rather than recording them:

  • The #[instruction] macro fails the build when the struct drifts from the snapshot.
  • While nothing is published, create replaces the snapshot.
  • After publication, a payload change fails with PublishedPayloadChanged: declare a new discriminator, or restore the published fields.
  • Appending optional accounts to a published snapshot extends it in place and consumes no version.
  • A published snapshot cannot gain an envelope (EnvelopeAddition); declare a new discriminator for the migration-aware instruction.
  • When an instruction stops asking to be recorded (migrations = false, or an auto policy that no longer covers instructions), check fails with StaleSnapshot and create releases the snapshot. Nothing on the wire changes.

#[instruction(discriminator = X, migrations)] opts one instruction into full migrations: a version envelope, adjacent transitions under migrations/transitions/instruction_<width>_<hex>/, and a generated dispatcher that normalizes a historical payload before the handler runs. Before publication, removing the token and running create turns it back into a snapshot; after publication it fails with EnvelopeRemoval. The macro reports the same situation at build time: “removing an envelope is a wire-format change that pina migrations create must record deliberately”. A program whose instructions were recorded under ABI 0.20 has them enveloped, and must add migrations to keep a published instruction’s wire format; see Upgrading from ABI 0.20.

Choose the version width

The width of the version field is program-wide, recorded as versionType in migrations/manifest.json, and u8 by default. Versions are counted per contract, so u8 gives every account, instruction, and event its own 255-version budget. Record a wider width only when one contract may need more:

pina migrations create --auto true --version-type u16

--version-type accepts u8, u16, or u32; there is no u64. A later run without the flag keeps the recorded width. While nothing is published (no receipt and no pending deployment), the flag rewrites the width in place and create prints the change; every history is still a single draft, so nothing else in the manifest moves. Rebuild and regenerate clients afterwards so they write the wider field. Once a deployment publishes, the width is frozen and the flag fails: “Migration version encoding is frozen as u8 because a deployment published it, so it cannot become u16”.

Capture a draft

Run the migration generator after the source ABI changes:

pina migrations create
pina migrations status

Pina writes migrations/manifest.json, migrations/publications.json, and adjacent transition files under migrations/transitions/.

If the current version has never been deployed to a non-local cluster, create replaces that draft. If a publication receipt or pending deployment contains the version, create appends the next version. A changed event appends a version with no transition file. A published instruction whose payload is unchanged but whose account list gained appended optional slots is extended in place instead (Appended optional accounts to instruction:1:00@0), and a published snapshot-only instruction cannot change its payload at all.

When a transition grows an account, create prints the estimated rent deficit (about 6,960 lamports per grown byte), names the program constant to raise (max_lamports, for example MAX_INLINE_MIGRATION_LAMPORTS), and points at the on-chain error an undersized budget produces: MigrationLamportBudgetExceeded. Cumulative worst-case growth across the supported stale ladder — every version a stale account may still hold within MAX_INLINE_STEPS, not only the adjacent hop — beyond the runtime’s 10,240-byte (MAX_PERMITTED_DATA_INCREASE) per-instruction realloc cap warns separately, because no budget raises that limit; it points at MigrationAccountGrowthExceeded. Both warnings quote the same numbers as the PinaProgramError rustdoc, so the pre-deploy estimate and a failed transaction name the same fix.

Commit the manifest, publication ledger, and transition files. Do not generate them during a build.

Preview deployment costs

pina migrations status ends with a cost preview derived from the checked-in history and, when the compiled SBF artifact exists, from pina profile’s static per-function estimates. It reports:

  • per account contract: current size, the bytes a version-0 (day-one) account grows, and the approximate rent deficit at the same 6,960-lamports-per-grown-byte convention the create warning uses;
  • per instruction process: the worst-case adjacent-step ladder a stale account the process names can trigger, with the step count, the rent that ladder funds, and a static CU estimate;
  • one program-wide summary that sizes both budgets deliberately: the touching transaction funding the most rent (max_lamports) and, independently, the longest worst-case ladder (MAX_INLINE_STEPS) — each names its instruction, because they need not be the same one.

The preview prints the ladder model and the CU model so the numbers stay interpretable. When the history has more than eight transitions, the quoted ladder starts partway up and a note explains that a version-0 account instead fails with MigrationUnavailable; the day-one growth figure still counts every pending byte.

Two models keep the numbers interpretable. Rent reuses RENT_EXEMPT_LAMPORTS_PER_BYTE from the create warning, and the CU estimate sums pina profile’s static estimates for the generated adjacent migrate functions, so it excludes executor overhead (resize, rent transfer, validation) and runtime branch or loop effects. An artifact that has not been built, cannot be parsed, or lacks a transition function makes the CU figure print an explicit CU unavailable: ... reason instead of a zero.

Instruction processes link to account contracts by account-slot name, and only a writable, non-signer slot can hold an account the executor migrates. A writable, non-signer slot that names no checked-in account contract produces an explicit note instead of silently costing nothing — the symptom of a renamed account or a slot typo. Read-only and signer slots (authorities, payers, programs) can never be migrated, so they are skipped without noise.

--json emits the status array unchanged under statuses plus the additive cost section:

{
	"statuses": [
		{
			"identity": "account:1:01",
			"kind": "account",
			"rustName": "State",
			"envelope": true,
			"currentVersion": 2
		}
	],
	"costPreview": {
		"rentLamportsPerByte": 6960,
		"maxInlineSteps": 8,
		"ladderModel": "the oldest version within MAX_INLINE_STEPS (8) of the current version, ...",
		"cuModel": "sum of `pina profile` static estimates for the generated adjacent `migrate` functions, ...",
		"artifact": "target/deploy/my_program.so",
		"contracts": [
			{
				"identity": "account:1:01",
				"currentSizeBytes": 44,
				"dayOneGrowthBytes": 2,
				"dayOneRentDeficitLamports": 13920
			}
		],
		"instructions": [
			{
				"identity": "instruction:1:00",
				"totalSteps": 2,
				"totalRentDeficitLamports": 13920
			}
		],
		"mostExpensive": {
			"status": "identified",
			"instructionRustName": "UpdateInstruction",
			"steps": 2,
			"rentDeficitLamports": 13920
		}
	}
}

A figure that cannot be estimated serializes as { "status": "unavailable", "reason": "..." }; pina migrations check --json still emits only the status array.

Disambiguate renames

A field that disappears while another field of the same type appears is ambiguous (types are compared on the wire, so PodU64 and u64 count as the same type): a rename preserves the stored bytes, a remove-plus-add discards them and starts the new field zeroed. pina migrations create refuses to guess:

  • On a terminal it prompts field by field and records the answer.
  • With --no-interactive, or when no terminal is attached, it fails with one line per question naming the exact flags that answer it:
pina migrations create --rename score:points        # preserve the renamed data
pina migrations create --assume-removed score       # discard it; `points` starts zeroed

--json emits the open questions as a machine-readable array so agents can parse, decide, and re-run. With --json, --no-interactive, or no terminal attached, an unanswered question is a hard failure with a defined contract: the question array prints on stdout, the human-readable error prints on stderr, and the exit status is 1; capture both streams and re-invoke with the flags each question names. Answered renames are recorded in the manifest transition, so repeated create runs never re-ask, the generated transition copies the field’s bytes, and --assume-removed prints a data-loss warning. Type changes and unpaired removals always fall back to a manual transition with a TODO body; nothing is dropped silently.

A third answer hands one field’s conversion to you. --manual <field> names an added field and makes the whole transition manual, so the generated file is a stub you complete instead of a byte move Pina chose:

# Combine `first_name` and `last_name` into `name` yourself.
pina migrations create --rename first_name:name --assume-removed last_name --manual name

--manual is also the way to make a rename whose type changed legal: a generated rename copies bytes verbatim and so requires the same wire type, while --manual records that you own the interpretation. The answer is recorded, so repeated create runs keep generating the manual draft rather than re-deriving an automatic transition over your body.

Answers that contradict each other fail closed rather than picking a winner, whichever source they came from: a field cannot be both renamed and discarded, and a manual conversion cannot also discard the stored bytes it reads.

Resolve a manual transition

Pina generates automatic transitions only for direction-safe fixed-layout changes: copies, insertions and removals that shift later fields, and zero-filled additions. An instruction argument is never zero-filled, because the handler could not tell that default from a value a client sent, so an instruction transition that adds an argument is always manual. A type change (including a widening such as u64 to u128, but not a respelling that stores the same bytes), a reorder of existing fields, a compact layout, an ambiguous field move, or a --manual answer creates a manual Rust file with TODO(pina-manual-migration). --manual <field> also converts a draft that create already recorded as automatic.

A finished body belongs to the two layouts it was written for. While the draft’s destination schema is unchanged, create keeps it. If you change the draft’s layout again, create moves the finished body to vN_to_vM.rs.stale, writes a new stub whose header prints the new offsets, and says so. The new stub’s marker blocks the build until you port the old body; delete the .stale file once you have. This keeps an old body from compiling against a layout it was not written for and silently misplacing bytes.

Every generated transition reads its byte offsets from the stored schema. A removed field keeps occupying its bytes, so a transition that drops a field in the middle of a layout still reads the fields after it from their original offsets — and its SOURCE_SIZE counts the bytes that are actually on the account.

Replace the generated body. Pina preflights the exact historical shape for every account. Fixed transitions have generated size constants and their generated migrate stub starts with a length guard; keep it. A transition involving compact data also has target_size and working_size functions. They inspect already-validated historical bytes and must return a valid destination allocation without mutating the account. The migrate function is then total for that accepted source and must fully initialize every active destination byte.

A manual account transition cannot reject a value it cannot interpret: by the time migrate runs, rent funding and resizing may already have taken effect, so a migrate that cannot produce a valid destination aborts the whole instruction instead of returning a catchable error. Validate unambiguous value constraints inside target_size and working_size (they run before any mutation) and reserve genuinely rejectable conversions for instruction transitions, which run in scratch space before any account is touched.

Pina runs adjacent account transitions one at a time inside one invocation. It validates and commits each intermediate version before planning the next, which lets a later compact allocation depend on the prior compact result without allocating a copy of the account on the SBF stack. If any later step fails, Pina aborts the instruction so Solana rolls back all earlier resizes, lamport transfers, and byte writes. A manual instruction conversion instead runs in scratch space and may reject invalid semantic values before dispatch. Then run:

pina migrations check
pina test --compatibility

check rejects a remaining marker. Once publication is pending or complete, it also rejects any change to the transition file or either schema hash. Fix frozen transition code with another migration version.

IDL generation runs the same check. The current IDL keeps each enveloped account, instruction, or event’s migrationVersion field in place with defaultValueStrategy: "omitted" and its default value: generated client inputs omit it, encoders stamp the current value into the envelope automatically, and decoders reject any other version. A snapshot-only instruction has no migrationVersion field. Each earlier version of an event is listed as its own event node, <Event>V<n>, so clients can decode old log records; historical account and instruction schemas and all transition code remain exclusively in migrations/manifest.json. The IDL follows the recorded policy, the same one the macros expand against. Without a manifest there is no policy, so neither the program nor the IDL carries an envelope, and a declaration with an explicit migrations token fails until create records it.

Change an instruction process

Trailing optional accounts may be omitted entirely from the end of an account list; every earlier slot must still be present, using the program address as a filler where a middle optional account is absent. Omitting a middle optional account without a filler shifts every later account into an earlier slot, which only surfaces as a confusing missing-account error or a privilege check failure. Treat a trailing Option account as fully untrusted: it can be absent from any request, not only from old ones.

Pina snapshots the instruction payload and its positional account list under the same instruction version. Appending optional accounts to a published version extends its recorded account list in place: no version is consumed and no transition is written. An old request remains compatible only when:

  • each existing account slot is unchanged;
  • existing slots keep the same order;
  • every new slot is appended at the end; and
  • every appended slot is optional.

A slot’s wire facts are its name, signer flag, writable flag, and optionality, and a change to any of them is breaking. Create a new instruction discriminator for a breaking process. A slot also records two client hints, its known address (defaultValue) and its PDA (pda), which say what generated clients fill in. A client that passes the address itself sends the same account list, so hints are never compared: a hint-only change is not drift, consumes no version, and leaves the recorded snapshot as it is until a wire change replaces it (ADR 0012). Declarative constraints are program semantics and are not recorded at all.

The runtime treats an omitted optional suffix as absent. It never creates an account, signature, writable privilege, or PDA for an old client.

Respell a type

A drift check compares what a field stores, not how its type is spelled. Spellings that store the same bytes under the same reading are one schema: the PodU16, PodI16, PodU32, PodI32, PodU64, PodI64, PodU128, PodI128, and PodBool wrappers and their native names, Address and [u8; 32], PodString<N> (or PodString<N, 1>) and String<N>, and PodVec<T, N> (or PodVec<T, N, 2>) and Vec<T, N>, recursively through arrays, Option, and vectors. Respelling PodU64 as u64 therefore consumes no version and fails no build, and a real change next to a respelling still gets an automatic byte copy for the respelled field. The recorded spelling and every pinned hash stay as they were.

Types that only share a width are different schemas: u64 to i64, u32 to f32, or u8 to bool reinterprets a live value, so each needs a manual transition.

Decode historical events

Events are immutable, so Pina versions them instead of migrating them. The program emits only the current version, and nothing converts an older record. Changing a published event’s schema appends a version with its own schema and no transition file.

Generated clients decode each version with its own schema. The IDL lists every earlier version as a separate event node named <Event>V<n> (for example ValueChangedEventV0) next to the current event. The program-level log parser (parse<Program>EventsFromLogs in TypeScript and Dart) routes each record by discriminator and version and throws on a version no generated event describes, for example event "valueChangedEvent" log carries migration version 2, which this client cannot decode; regenerate it. In Rust, each event’s try_from_bytes separates a stale record from a future one and names the event to decode it with. A decoded record holds exactly the fields its version emitted; no field is zero-filled. Keep golden log bytes in pina_test::HistoricalEvent and decode them with the generated event for their version rather than encoding fixtures with the current event type.

Publish a version

Use pina deploy for a persistent cluster. Before the remote command starts, Pina atomically records the cluster, RPC target, executable digest, and the pinned history of every contract it ships as pending. A pending version is frozen because it may already be live. After deployment succeeds, Pina rechecks the planned files and appends that record to migrations/publications.json as a receipt:

{
	"abiVersion": "0.21",
	"receipts": [
		{
			"rpcUrl": "https://api.devnet.solana.com",
			"executableSha256": "…",
			"versions": {
				"account:1:01": [
					{ "schemaSha256": "…" },
					{ "schemaSha256": "…", "transitionSha256": "…" }
				]
			}
		}
	]
}

Each contract maps to the list of its pins: entry n pins version n with its schema hash and, when it has one, the hash of the transition that enters it. The highest version a receipt made live is the position of its last pin, and every receipt pins every version it made live, so rewriting published history — even with consistently recomputed hashes — fails every later check. A receipt names no program: the ledger belongs to the manifest beside it, whose programId is the only copy. Receipts are appended in deployment order, and version control is what keeps the file append-only; a hash chain over the receipts could be recomputed by anyone able to edit them, so none is stored.

Local deployments do not publish versions unless you pass --record-publication. A published version is immutable even if a later deployment replaces it.

If the deploy program cannot even start, for example because solana is not on PATH, nothing can have reached the cluster, so Pina discards the pending record it just wrote and your drafts stay editable. A pending record that an earlier attempt left behind is never discarded this way.

If deployment or receipt recording fails, stop the release. The pending record remains, and pina migrations status reports publication pending. Restore the exact planned inputs and rerun the same deployment to reconcile it; Pina rejects a different deployment while the outcome is ambiguous. The current ledger does not prove the deployed program-data hash, genesis hash, slot, or transaction signature.

When the exact planned inputs cannot be reproduced (for example a cleaned build directory), inspect the pending deployment with pina migrations reconcile. It prints the cluster, RPC endpoint, program, and executable digest that must be resumed. Once you are certain the deployment never went live, pina migrations reconcile --abandon converts the pending record into an abandoned receipt that still freezes its pinned versions and unblocks the next deployment. Losing migrations/publications.json entirely fails every later check while the manifest still records advanced versions, because published history must stay pinned; restore the ledger from version control instead of regenerating it.

A contract entry with no pins is invalid, and every command refuses the ledger (“pins no versions”), because such an entry cannot tell a rewritten published schema from the one that shipped; restore the file from version control rather than blanking a history. An ABI 0.20 ledger could name a published version without pinning it, and reading one fails with an error naming pina migrations reconcile --pin-legacy. After confirming from version control that migrations/manifest.json still records exactly what those receipts shipped, run that command once: it pins every such entry from the manifest (trust on first use), writes the ledger in the current shape, and leaves you to commit it. A 0.21 ledger has nothing to pin.

The manifest records the program ID its history belongs to. Before anything is published, pina migrations create rebinds the history to a changed declare_id! (for example after pina keys new) and says so. After publication, a different declare_id! fails every command until the original is restored.

ABI document upgrades

abiVersion belongs to Pina’s migration document. It is independent of each account or instruction version, and it names the pina_abi release line that wrote the document: the value is the major.minor committed in crates/pina_abi/ABI_VERSION, which advances with a breaking pina_abi release and with nothing else.

Reads reject a document stamped above the running build, naming the supported version, and reject one below the oldest supported version with the remedy that regenerates it. Anything between is normalized through an ordered table of adjacent converters before the typed model is read, so a document an older release wrote still opens. Conversions run in memory only: no command rewrites a checked-in document as a side effect of reading it.

The 0.20 reset replaced the integer formatVersion counters, and documents from older releases must be converted once before any Pina command can read them — create and sync included. Migrate to the reset ABI document walks the conversion for both deployed and not-yet-deployed programs.

ABI 0.21 stores each fact once: the contract key kind:width:hex is the identity, the wire codec is implied by abiVersion, a version without a transition omits the key, and a process slot omits every field left at its default. It also added "envelope": false for instructions recorded without an envelope and removed event transitions. The publication ledger keeps only what nothing else records: a receipt is its rpcUrl, executableSha256, pinned versions, and abandoned flag. A 0.20 document is converted in memory and rewritten by the next create; Upgrading from ABI 0.20 lists the source changes that can follow.

pina abi schema prints the JSON Schema for a document, generated from the same types that read and write it:

pina abi schema --document manifest > manifest.schema.json
pina abi schema --document publications

Each version’s schema is checked in under crates/pina_abi/schemas/, frozen beside its fixture under crates/pina_abi/fixtures/<version>/, and published under this book at a permanent URL — https://pina-rs.github.io/pina/abi/schemas/<version>/manifest.schema.json — which its $id names. Only the version this build writes can be printed; an older version’s shape is recorded by its frozen fixture rather than reproduced under a new build.

An ABI document upgrade does not consume an on-chain migration version.

Commands

CommandResult
pina migrations createCapture source changes and create or refresh one draft
pina migrations create --auto <POLICY>Record which contract kinds are tracked without a migrations token
pina migrations create --version-typeRecord the version width while nothing is published
pina migrations checkFail on source, schema, process, transition, or version drift
pina migrations statusShow each current version, its publication state, and the cost
pina migrations syncRun create, pina build, and pina generate for unambiguous changes
pina migrations inspect <ADDRESS>Compare one on-chain account’s envelope with the manifest
pina migrations reconcile [--abandon]Explain or abandon an ambiguous pending deployment
pina migrations reconcile --pin-legacyPin an ABI 0.20 ledger’s unpinned receipts from the verified manifest

Add --json for machine-readable output. Add --project <DIR> to select a program from another directory.

pina verify

Verify deployed executables and publish source-build records with the official solana-verify 0.5.1 workflow.

Start with a deterministic build:

pina build --verify

That command prints a content-addressed JSON record beside its .so artifact under target/pina/verifiable. Pina re-reads the record, rejects filesystem aliases and unsupported schema/tool versions, and recomputes the artifact’s trailing-zero-aware executable hash before it uses any provenance.

Compare a deployment

pina verify check \
  --program-id <ADDRESS> \
  --cluster devnet

Use --program <PROGRAM.SO> to select an executable directly or --project <DIR> to discover target/deploy/<library>.so. Pina delegates both hashes to solana-verify: executable hashing removes trailing zero padding before SHA-256, matching the official deployed-program comparison.

Exit codeMeaning
0The local and deployed executable hashes match.
2The comparison completed, but the executable hashes differ.
1Validation, tool execution, or RPC access failed.

check is read-only.

Record verified source

pina verify record \
  --program-id <ADDRESS> \
  --cluster devnet \
  --build-record ./target/pina/verifiable/my_program-<HASH>.json \
  --authority ./upgrade-authority.json

Before invoking the repository rebuild, Pina verifies that the record’s artifact hash matches the deployed program. It then passes the record’s public repository, full revision, mount path, workspace path, library name, Cargo features, and default-feature choice to verify-from-repo. Arbitrary repository or revision overrides are intentionally unavailable.

Interactive recording prints a plan and requires typing record. Automation must pass --yes. Submissions to mainnet and unknown remote RPC origins also require --acknowledge-mainnet; --yes does not imply that acknowledgement. Exports are non-mutating and require neither flag. The authority keypair also pays transaction fees because solana-verify 0.5.1 does not support a separate payer.

Pina recognizes the upstream 0.5.1 hash-mismatch transcript as exit code 2. This guard matters because that upstream release exits successfully when its repository rebuild differs and does not write a verification record.

Repository recording uses upstream clone and Docker behavior and is supported only on Linux and macOS. Read-only comparison and remote-status commands remain available when the exact executable works on another platform.

Export for a multisig

pina verify record \
  --program-id <ADDRESS> \
  --cluster mainnet-beta \
  --build-record ./target/pina/verifiable/my_program-<HASH>.json \
  --export <MULTISIG_ADDRESS> \
  --output ./verification.tx \
  --export-encoding base64

Export never submits. It runs the separate upstream export-pda-tx operation after Pina’s build-record/deployment hash preflight. That upstream operation encodes the source and build arguments but does not rebuild the repository; remote verification happens only after the transaction is submitted. Upstream progress remains diagnostic output; only the final validated base58 or base64 transaction payload is atomically written to --output. Supplying a public address after --export does not require an authority secret.

Remote verifier

After an on-chain record exists:

pina verify submit --program-id <ADDRESS> --uploader <ADDRESS>
pina verify status --program-id <ADDRESS>

These commands target the official mainnet remote verifier. uploader is the public address that created the record, not a keypair. Status is read-only.

Tool and RPC security

Pina requires the exact version string solana-verify 0.5.1 and never installs it. Select an executable explicitly with pina verify --solana-verify <COMMAND> ....

Cluster aliases are mainnet-beta, devnet, testnet, and localnet. A custom endpoint must be a credential-free HTTPS origin with no path, query, or fragment; loopback HTTP is allowed for local development. RPC endpoints are necessarily passed to solana-verify in process arguments, where the operating system’s process listing may expose them. Put no credentials or access tokens in an RPC URL.

Authority files must be private regular files, not symbolic links. Pina bounds the file size, parses the 64-byte Solana keypair, and cryptographically verifies that its public half matches its secret half before starting the network workflow. Before confirmation, Pina freezes the reviewed provenance in memory and copies the validated keypair into a private temporary file so replacing the original paths cannot change the approved submission. Keypair bytes are never printed or passed in arguments; only that private temporary path is passed to upstream --keypair.

pina generate

Refresh the current program’s IDL and generate selected client ecosystems.

Synopsis

pina generate [OPTIONS]
InputDefaultMeaning
-p, --project <DIR>current directoryStart directory for project discovery.
--client <LANGUAGE>pina.tomlcpi, rust, typescript, dart, cli-rust, cli-ts, or cli-dart; repeatable.
-o, --output <DIR>configured outputOverride the client output root.
--mode <MODE>pina.tomlOverride with auto, create, update, or overwrite.
--no-scaffoldoffGenerate sources without creating manifests or entrypoints.
--npx <COMMAND>npxCodama runner for TypeScript or Dart.
pina generate
pina generate --client rust
pina generate --client cpi
pina generate --client typescript --client dart
pina generate --client cli-rust
pina generate --client cli-ts
pina generate --client cli-dart
pina generate --mode create
pina generate --mode update --no-scaffold
pina generate --mode overwrite

Repeating a language is harmless. Explicit --client values replace the configured list for that invocation. CPI-only, Rust-only, and cli-rust generation do not invoke Node.js. Each CLI variant implies its base client (cli-rust ⇒ rust, cli-ts ⇒ typescript, cli-dart ⇒ dart); projects normally pick one CLI, and Pina warns when several are selected together.

auto initializes an empty destination and otherwise updates it. Updates replace only renderer-owned generated source, preserving customized manifests and crate/package entrypoints. create and update enforce the expected destination state. overwrite deletes the entire selected client target before regeneration; use it for an intentional clean sweep. Command-line --mode and --no-scaffold override every selected target for that invocation.

Outputs are grouped by ecosystem:

clients/
├── cpi/<library-name>/
├── rust/<library-name>/
├── typescript/<library-name>/
├── dart/
├── cli-rust/<library-name>/
├── cli-ts/<library-name>/
└── cli-dart/

Pina rejects filesystem-root and symbolic-link generation targets before a renderer runs.

Generate every program in a repository by running the command once per project; scripts/generate-pina-clients.sh does exactly that for this repository’s examples.

Compute unit limits

When the program has a compute-units.json recorded by pina test --record-compute-units, the IDL carries each measured instruction’s budget as a pinaComputeUnits plugin node, and every client requests a tight, evidence-based limit instead of the runtime default:

{
	"kind": "pluginNode",
	"name": "pinaComputeUnits",
	"payload": { "measured": 379, "limit": 800 }
}

The limit is computed once, here, and copied by every generator:

limit = round_up_to_100(measured × (100 + margin_percent) / 100) + 300
  • margin_percent comes from [compute_units] in pina.toml and defaults to 20, so a limit absorbs inputs more expensive than the recorded fixtures. The margin rounds up, and rounding to a hundred keeps limits stable when a measurement moves by a few units.
  • The 300 units cover the compute budget instructions a priority-fee transaction carries: SetComputeUnitLimit and SetComputeUnitPrice each consume 150 compute units. Pina’s test suite measures both rather than assuming them.
  • The limit never exceeds the 1,400,000 unit transaction maximum. An instruction whose measurement leaves no room for the compute budget instructions fails generation.

Each client exposes the budget its own way:

ClientWhat it gets
Rust<NAME>_MEASURED_COMPUTE_UNITS and <NAME>_COMPUTE_UNIT_LIMIT next to each discriminator, plus a crate-level set_compute_unit_limit_instruction(units).
TypeScriptThe same constants and get<Program>ComputeUnitLimit(instructions), which sums the limits of the program’s instructions in a transaction, ready for setTransactionMessageComputeUnitLimit.
Dart<name>MeasuredComputeUnits, <name>ComputeUnitLimit, and get<Program>ComputeUnitLimit(instructions).
cli-rust, cli-ts, cli-dartEvery command adds one SetComputeUnitLimit with its instruction’s limit. --compute-unit-limit <UNITS> overrides it, and --simulate reports consumption against the limit requested.

The summing helpers return no limit when a transaction carries no instruction for the program, or one without a measurement, so the runtime default applies. Their sum is conservative: every limit carries its own margin and compute budget reserve, which a transaction pays only once. They do not count instructions for other programs; add those budgets yourself.

import { setTransactionMessageComputeUnitLimit } from "@solana/kit";
import { getCounterProgramComputeUnitLimit } from "./clients/typescript/counter_program/src/generated";

const limit = getCounterProgramComputeUnitLimit(instructions);
const budgeted = setTransactionMessageComputeUnitLimit(limit, message);

An instruction without a measurement gets no plugin, and its clients are unchanged. CPI clients never set a limit: a compute unit limit belongs to the outer transaction.

A measurement names an instruction by its IDL name. If compute-units.json names one the program no longer declares, pina generate fails until you run pina test --record-compute-units again or remove the stale entry (or the whole file), so a renamed instruction cannot silently lose its budget in committed clients. pina build, pina idl, and pina test print a warning naming the stale entries and ignore them, so they never block the recording that replaces them. When the build in target/deploy differs from the one recorded in artifactSha256, pina generate still generates the limits and prints a warning naming the command that measures the current build.

See Project Configuration for client defaults and the distinction between configuration-relative and command-line paths.

pina cpi

Generate a standalone, no_std Pina CPI crate from a Codama or Anchor IDL.

Synopsis

pina cpi (--idl <FILE> | --stdin) --output <DIR> [OPTIONS]
InputDefaultMeaning
--idl <FILE>noneCodama or raw Anchor IDL to normalize and render.
--stdinoffRead a normalized Codama root from a visitor pipeline.
-o, --outputnoneDirectory for the generated standalone crate.
--mode <MODE>autoauto, create, update, or complete overwrite.
--no-scaffoldoffGenerate src/generated without Cargo.toml or src/lib.rs.
--npx <COMMAND>npxRunner for raw Anchor conversion; unused for Codama IDLs.
pina cpi --idl ./target/idl/counter.json --output ./clients/counter-cpi
pina cpi --idl ./anchor-idl.json --output ./clients/anchor-cpi
pina cpi --idl ./anchor-idl.json --output ./clients/anchor-cpi --mode update

Instruction arguments may be little-endian native integers (u8-u128 and i8-i128), booleans, public keys, fixed u8 arrays, PinaPod’s bounded strings and vectors, and anything reachable through the IDL’s definedTypes: structs, enums, options, tuples, length-prefixed arrays, and maps. Anchor IDLs route most non-primitive arguments through that table, so a real third-party IDL renders without hand-editing.

Floating point, shortU16, bare (unprefixed) strings and byte slices, and big-endian integers are rejected before rendering, each with the reason in the error message.

An instruction whose arguments are all fixed-width returns [u8; LEN] from to_bytes(). When an argument’s length depends on caller-supplied data the instruction instead exposes MAX_DATA_LEN and takes a caller-owned buffer through encode_into — which bounds-checks every write and returns ProgramError::InvalidInstructionData instead of panicking when the buffer is too short or an argument exceeds its declared limit — before invoke_signed sends the encoded bytes, so the crate stays no_std and allocator-free either way.

Instructions using Anchor’s omitted optional-account strategy are rejected with the instruction named — a fixed-size CPI account array cannot drop an absent account the way that strategy requires. Pass --skip-unsupported-instructions to render the remaining instructions anyway; each skip and its reason is recorded in the generated instructions/mod.rs.

Accounts from the IDL render into a generated/accounts module with a struct, the account discriminator, an encoded-size constant, a matches guard, and — when every field has a fixed offset — a parse function.

To adopt another program’s interface end to end, including provenance and the program-ID binding test, see Import a Foreign Program.

Codama roots are rendered natively. Raw Anchor IDLs are normalized with @codama/nodes-from-anchor, then passed to the same renderer. The output crate contains a validated ProgramAccount and direct struct-based calls exposing .invoke() and .invoke_signed(). Each call contains its account references and a typed *Ix field named ix; its to_bytes() output is passed as CPI data. Account and argument fields preserve their IDL documentation and are labelled with their role.

The default auto mode creates missing scaffold files on initial generation and preserves them on updates. create fails if the target is nonempty, update fails if it is absent or empty, and overwrite removes the entire crate before generating it again. Use --no-scaffold when a workspace already supplies its own manifest and entrypoint.

The CLI test suite passes a committed raw Anchor IDL through the real pinned converter, checks the generated struct and documentation surface, and runs cargo check on the standalone no_std crate. The pina_bpf example also consumes the generated Prop AMM CPI client in its signer and PDA-signer execution paths.

Use pina generate --client cpi when the source is the current Pina program. For a reusable Codama script, install @pina-rs/codama-renderer-cpi; Codama normalizes either source format before passing its current transformed root to the visitor.

Import a Foreign Program

Call another on-chain program from your own without hand-writing its wire format. pina import fetches a foreign program’s IDL, renders a standalone no_std CPI crate, and records what the crate was generated from.

Synopsis

pina import <NAME> --program-id <PUBKEY> [OPTIONS]
InputDefaultMeaning
<NAME>requiredCrate name; written to clients/cpi/<NAME>.
--program-idrequiredProgram ID the crate targets.
--idl <FILE>noneRead the IDL from a local file.
--url <URL>noneFetch the IDL over HTTPS. Plain HTTP is accepted only from localhost/127.0.0.1 for development.
--clustermainnet-betaFetch the on-chain canonical IDL for this cluster.
--output <DIR>clients/cpiDirectory to write the crate into.
--mode <MODE>autoauto, create, update, or complete overwrite.
--npx <COMMAND>npxRunner for Anchor IDL conversion.
--skip-unsupported-instructionsoffSkip instructions the renderer cannot express instead of failing; each skip is recorded in the generated instructions/mod.rs.

Give one of --idl, --url, or --cluster. Passing --idl with --url is rejected rather than silently preferring one.

# From a vendored IDL
pina import switchboard \
  --program-id SBondMDrcV3K4kxZR1HNVT7osZxAHVHgYXL5Ze1oMUv \
  --idl ./idls/on_demand.json

# From a URL
pina import metaplex \
  --program-id metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s \
  --url https://example.com/token_metadata.json

# From the program's published on-chain IDL
pina import squads \
  --program-id SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf \
  --cluster mainnet-beta

What the generated crate contains

Each instruction becomes a call struct holding its accounts in the target program’s own order plus a typed ix field for the arguments:

RandomnessReveal {
    randomness,
    oracle,
    queue,
    ix: RandomnessRevealIx { signature, recovery_id, value },
}
.invoke_signed(program, &[signer])?;

Each account in the IDL also gets a read-only parser:

use switchboard_cpi::accounts::randomness_account_data::RandomnessAccountData;

let state = RandomnessAccountData::parse(randomness.try_borrow()?.as_ref())
    .ok_or(ProgramError::InvalidAccountData)?;
if state.reveal_slot == clock.slot {
    let value = state.value;
}

The parser carries the account’s discriminator as a public constant, its encoded size as LEN (or MAX_LEN when the layout varies), a matches guard, and a parse that returns None on a short buffer or a foreign discriminator.

An account whose layout has a variable-width field still gets its struct, discriminator, and size constant, but no parse: a variable-width field leaves every field after it with no fixed offset, so a partial parser would read the wrong bytes. The generated crate records why in a PARSER_UNSUPPORTED constant so the omission is visible at the call site.

Provenance

pina import stamps the generated README with the IDL’s SHA-256, where it came from, and the generator version:

FieldValue
Program IDSBondMDrcV3K4kxZR1HNVT7osZxAHVHgYXL5Ze1oMUv
IDL sourcefile ./idls/on_demand.json
IDL SHA-256336e8714...
Generatorpina_cpi_renderer 0.18.0

The digest is the contract a reviewer checks. A CPI crate is a copy of another program’s interface, so without a recorded digest a reviewed crate can be regenerated from a different IDL with nothing in the diff to show it.

Re-running the same import against an unchanged IDL reports already up to date and rewrites nothing, so an import checked into CI produces no drift.

Binding the program ID

The target program address is compiled into the crate as an Address constant, and is_expected_program compares an address against it. Use it when the address arrives from caller input:

if !switchboard_cpi::is_expected_program(oracle_program.address()) {
    return Err(ProgramError::IncorrectProgramId);
}

The generated crate also ships a unit test that binds the compiled-in constant to the address spelled in the IDL. A swapped dependency could otherwise retarget every CPI in the crate without the source changing, so the expected address is asserted in full rather than only through the constant.

Supported argument shapes

Anchor routes most non-primitive arguments through definedTypes. The renderer resolves those links and declares the structs and enums it needs as Rust types with their own encoders, so nesting stays recursive instead of unrolling into the instruction body.

Supported:

  • little-endian integers u8-u128 and i8-i128, booleans, and public keys
  • fixed-width byte arrays
  • length-prefixed strings, byte slices, arrays, and maps
  • Option, including Anchor’s variable-length form
  • structs, tuples, and enums whose variants carry equally sized payloads
  • structs and enums referenced through definedTypeLinkNode

Rejected, with the reason in the error message:

ShapeWhy
shortU16Anchor encodes it as a 1-3 byte variable-length prefix reserved for account lengths.
f32 / f64A no_std crate has no float ABI conversion, so the writer would silently disagree.
Bare stringWithout a length prefix a reader cannot tell where the value ends.
Bare bytesSame; wrap it in sizePrefixTypeNode or fixedSizeTypeNode.
Big-endian intsSolana instruction data is little-endian.
Uneven enumsVariants with different payload widths have no single instruction-data layout.

Optional accounts

Anchor’s programId strategy — where an absent optional account becomes the program ID — is supported directly.

The omitted strategy, where an absent account is dropped from the list entirely, cannot be expressed by a fixed-size CPI account array — filling the slot with a placeholder would send the callee an account it does not expect. Instructions using this strategy therefore fail the import with the instruction named. Pass --skip-unsupported-instructions to import the rest of the program: the skipped instructions and the reasons are written into the generated instructions/mod.rs, so the gap is visible to every reviewer of the crate.

Verifying an import

The same gate that renders every checked-in fixture compiles the result for bpfel-unknown-none:

./scripts/verify-cpi-fixtures-sbf.sh

Checked-in fixtures cover Switchboard On-Demand randomness, Metaplex Token Metadata, Meteora DLMM, and Squads v4 multisig. The Switchboard fixture pins its instruction discriminators, account counts, and encoded instruction lengths against the hand-written reference crate in pina-rs/lootbox, and they match exactly.

Project Configuration

pina.toml is the project marker used by pina build and pina generate. Pina searches from the command’s --project directory, or the current directory, through its ancestors and uses the nearest configuration file. The legacy uppercase Pina.toml spelling is still discovered but deprecated and prints a warning, so new projects should use pina.toml.

The complete schema is intentionally small. Every section is optional; an empty file uses every default, and unknown sections and fields are rejected so misspellings cannot silently change a build.

[project]
program = "." # directory containing the program Cargo.toml
# idl_dir = "target/idl"             # override where generated IDLs are written

# Optional named path anchors. Values may reference `{{root}}`.
[project.paths]
sdks = "{{root}}/sdks"

[clients]
output = "clients" # root directory for generated clients
languages = ["cpi", "rust", "typescript"]
mode = "auto" # destination lifecycle policy
scaffold = true # initialize missing manifests and entrypoints

[lints] # optional per-lint level overrides
require_canonical_instruction_dispatch_for_idl = "deny"

[locks] # optional intentional write-lock hotspots for `pina locks`
allow = ["program_config"]

# Optional persisted disambiguation answers for `pina migrations create`.
[migrations.answers]
rename = ["value:points"]
assume_removed = []
manual = []

# Optional margin applied to recorded compute unit measurements.
[compute_units]
margin_percent = 20

# Optional target-specific overrides. Any of output, mode, and scaffold may be set.
[clients.cpi]
output = "onchain/cpi"
scaffold = false

[clients.dart]
mode = "update"

Path fields and anchors

Configuration paths are resolved relative to the directory containing pina.toml. Paths may also climb out of the project directory with .., or anchor at the repository root with template anchors:

  • {{root}} expands to the git repository root — the working-tree top level, discovered with git rev-parse --show-toplevel. Linked worktrees resolve to the worktree itself, so each worktree gets its own output locations. Without a git binary, Pina falls back to the nearest ancestor containing a .git entry (a directory for repositories, a file for worktrees). Using {{root}} outside a repository is an error.
  • A declared anchor such as {{ sdks }} expands to the value of the [project.paths] entry of the same name. Entry values may only reference {{root}} — they cannot reference each other — which keeps resolution single-step and cycle-free. Anchor names must match [A-Za-z_][A-Za-z0-9_-]*; root is reserved. Whitespace inside the braces is allowed ({{ sdks }} and {{sdks}} are the same anchor).

The discovery directory for {{root}} is the pina.toml folder, so a config nested in programs/my_program/ can still write clients to {{root}}/clients. The git root is only consulted when an anchor is actually used.

Validation rules for every configured path:

RuleBehavior
Non-emptyAn empty or whitespace-only path is rejected.
Relative or anchoredLiteral absolute paths (/tmp/clients, C:\tmp) are rejected; write {{root}}/... instead so the anchor is explicit.
.. traversalAllowed, including paths that leave the project directory. Traversing above the filesystem root is rejected.
Symbolic linksExisting components beneath the trusted base (the project directory or the repository root for anchored paths) may not be symbolic links. Generation-time checks still refuse destinations that are themselves links, link-like targets, filesystem roots, or a git working-tree root.

Command-line path overrides follow normal shell behavior instead: pina generate --output <DIR> resolves a relative directory from the caller’s current working directory, and .. is accepted. Standard Cargo variables remain supported. In particular, a relative CARGO_TARGET_DIR is resolved by Cargo metadata and passed to the compiler as an explicit absolute target directory.

[project] fields

FieldRequiredDefaultMeaning
project.programno.Directory containing the program Cargo.toml. May contain .. or anchors. The directory must contain a Cargo package with a library (or cdylib) target.
project.idl_dirnoCargo target directory/idlOverride for generated IDL files. pina build writes <idl_dir>/<library-name>.json.
project.paths.<name>no—Declares a named path anchor usable as {{ name }} in any path field. Values may reference {{root}}.

[clients] fields

FieldRequiredDefaultMeaning
clients.outputnoclientsRoot directory for generated client ecosystems. Resolved relative to the pina.toml directory, and every configured output must resolve inside the project’s Git worktree; pass --output on the command line to publish outside it.
clients.languagesnorust, typescriptWhich ecosystems to generate: any of cpi, rust, typescript, dart, cli-rust, cli-ts, and cli-dart.
clients.modenoautoDefault destination policy for every selected client; see the mode table below.
clients.scaffoldnotrueWhether missing package-level files (manifests, entrypoints) are initialized around the generated sources.

<target> in the per-client table below is one of the language names. Dart is the Dart and Flutter target; there is no separate Flutter generator.

FieldRequiredDefaultMeaning
clients.<target>.outputnotarget nameDestination beneath clients.output. Anchored values may reach anywhere inside the Git worktree, never outside it.
clients.<target>.modenoclients.modeDestination policy for one target.
clients.<target>.scaffoldnoclients.scaffoldScaffold policy for one target.

Selecting a CLI target implies its base client (cli-rust ⇒ rust, cli-ts ⇒ typescript, cli-dart ⇒ dart), and projects normally pick one CLI; selecting several prints a warning. CLI apps render into clients/cli-rust, clients/cli-ts, and clients/cli-dart respectively — the Dart CLIs share one package at cli-dart with a bin/<library-name>.dart executable per program. Override a CLI target under the matching table ([clients.cli_rust], [clients.cli_ts], [clients.cli_dart]), using the kebab spelling as a deprecated alias ([clients.cli-rust]).

Override the selection for one run with repeatable --client cpi, --client rust, --client typescript, --client dart, --client cli-rust, --client cli-ts, or --client cli-dart flags.

[lints] fields

FieldRequiredDefaultMeaning
lints.<lint-name>nobuilt-in levelPer-lint override: allow, warn, or deny. Names are validated against the bundled lint catalog; see Run Security Lints for the full lint-level workflow.

[locks] fields

FieldRequiredDefaultMeaning
locks.allowno[]Hotspots the program keeps on purpose, by account name, such as an admin configuration only admin instructions write. pina locks still reports them, marked allowed, and --deny-hotspots ignores them. Every entry must name a current hotspot; an entry that names none fails the command. See Report Write Locks.

[migrations.answers] fields

These answers are replayed by pina migrations create so fresh clones and CI repeat a decision made once. See the migration flow for the on-chain behavior; this section covers only the configuration.

FieldRequiredDefaultMeaning
migrations.answers.renameno[]Persisted rename answers in from:to form (for example "value:points"). pina migrations create consults them before prompting; command-line flags override them per field, and a contradicting flag fails closed.
migrations.answers.assume_removedno[]Persisted data-dropping acknowledgements, replayed the same way. assume-removed is accepted as a deprecated alias.
migrations.answers.manualno[]Persisted --manual answers: added fields whose conversion is written by hand rather than generated.

The migration policy is not configured here. The version envelope width and the auto policy live only in migrations/manifest.json, the one source macros read, and pina migrations create --version-type and --auto record them there; see Manage ABI migrations. The retired [migrations].version_type (or version-type) and [migrations].auto keys fail every command that reads pina.toml, with an error naming the flag and value that replace them, for example:

`[migrations].auto` no longer belongs in pina.toml: migrations/manifest.json records it, and macros read only the manifest. Remove the key and run `pina migrations create --auto true` to record it.

Hand-editing the manifest, migrations/publications.json, or generated transition files is never allowed. Individual contracts can still opt out of a recorded policy with an explicit migrations = false attribute.

[compute_units] fields

These settings turn the measurements pina test --record-compute-units writes to compute-units.json into the compute unit limits generated clients request. See compute unit limits for the formula and what each client receives.

FieldRequiredDefaultMeaning
compute_units.margin_percentno20Percentage added to each measurement before it is rounded up to a hundred and the compute budget instructions’ 300 units are added. Raise it when real inputs cost more than the recorded test fixtures. margin-percent is accepted as an alias.

The margin applies at generation time, so changing it and running pina generate updates every limit without recording again.

Generation modes

Generation defaults to mode = "auto": it initializes an empty target, then updates only renderer-owned source directories on later runs. Use mode = "create" or mode = "update" to enforce the expected state. The CLI equivalents are --mode and --no-scaffold.

ModeEmpty or missing destinationExisting nonempty destination
autoInitial generationUpdate generated source
createInitial generationFail
updateFailUpdate generated source
overwriteInitial generationRemove the recorded Pina-owned files, then regenerate

overwrite is an operator decision, not a configuration value: pina.toml cannot select it, and requesting destructive regeneration takes --mode overwrite on the command line. A pina.toml ships with the repository, so it is not trusted to authorize removing directories.

Deletion is bounded by a tracked-files record (.pina-generated.json) written at each client root: regeneration and overwrite remove only the paths a previous Pina run recorded, so files you added to a generated tree survive, and a directory Pina never generated is refused instead of removed. A destination that predates tracked manifests — generated by an older Pina — is refused by overwrite with a remedy in the error; remove it by hand once, or generate without overwrite once to record its files. Pina still refuses filesystem roots, git working-tree roots, symbolic-link targets, and output trees containing symbolic links.

Package resolution for renderers

The default --npx npx runner resolves the pinned renderer packages through the npx/pnpm dlx cache, from an isolated working directory with project-local entries removed from PATH: a committed node_modules in the project can neither shadow the pinned packages nor execute during generation. Passing a Node executable explicitly (--npx node, or a path to one) is the project-package mode — the documented way to resolve renderers from the project’s own install on purpose, which is also what keeps generation offline.

What generation owns versus what it scaffolds

Every client target separates two kinds of files, and knowing the split tells you what is safe to customize:

  • Renderer-owned files are rewritten on every generation and must never be hand-edited. Update mode replaces only these.
  • Scaffolded files are created once, only when missing, and are yours afterwards. Later runs never rewrite them, so manifests can be renamed, repackaged, or extended freely after the first generation.
TargetRenderer-owned (every run)Scaffolded once (only when missing)
rustsrc/generated/** — a mod.rs-rooted module treeCargo.toml, src/lib.rs (the two-line shim pub mod generated; pub use generated::*;)
cpisrc/generated/**Cargo.toml, src/lib.rs — the crate name honors the program’s Cargo package name
typescriptsrc/generated/**package.json
dartlib/src/generated/<program>/** plus the generated library barrelspubspec.yaml
cli-rustApplication sources (src/**) and a .pina-generated markerCargo.toml, README.md
cli-tsThe complete application, including package.json— (everything is renderer-owned)
cli-dartApplication sources (lib/src/<program>/**, bin/**, guard files)pubspec.yaml

Set scaffold = false to generate only renderer-owned files and never create package-level files — the “files, not the package” mode for embedding generated sources into a crate or package you own. Note that the Rust src/generated tree references crate::<PROGRAM>_PROGRAM_ID (the upper-snake library name), which the default lib.rs shim satisfies by glob re-exporting generated::*; an embedded tree needs an equivalent re-export at its host crate root.

An update owns only renderer-owned paths. The Rust and CPI renderers refuse to replace a src/generated tree whose mod.rs lacks Pina’s autogenerated header, and cli-rust update refuses a crate without its .pina-generated marker, so trees written by hand are never overwritten.

Names are read back from scaffolded manifests on later runs: the Rust CLI derives its dependency path from the crate name in the existing Cargo.toml, and the Dart CLI derives its package import from the name: in the existing pubspec.yaml. Renaming a client in its manifest after first generation is therefore supported and respected.

Because a scaffolded package.json is never rewritten, its @solana/kit range stays wherever the first generation left it even as the generated sources around it move forward with the renderer toolchain. The same applies to a scaffolded Dart pubspec.yaml and its Solana Kit Dart ranges. Hand-editing those ranges is the supported way to move a client between Kit versions: later runs respect whatever is written.

Generation refuses to update a client only when the existing manifest provably cannot resolve the Kit version the generated sources compile against — @solana/kit for TypeScript, the solana_kit_* packages for Dart, in either the inline or the nested version: declaration form. It judges a range by what it can resolve rather than by its first number, so a bounded range like >=7 <9 or a union like ^8.3.0 || ^7.0.0 passes because npm installs the newer version, while ^7.0.0 fails. The error names the manifest and the range to raise, and overwrite mode is the explicit way to start the scaffold over on the current ranges.

Example configurations

Defaults only — generates Rust and TypeScript clients into ./clients next to the program:

# pina.toml

A program publishing an SDK in every language, with clients at the top of the repository rather than inside the program directory:

[project]
program = "programs/lootbox"

[clients]
output = "{{root}}/clients"
languages = ["cpi", "rust", "typescript", "dart"]

[clients.cpi]
output = "{{root}}/crates/lootbox-cpi"

The same layout expressed once through named anchors, useful when several fields share a prefix:

[project]
program = "programs/lootbox"

[project.paths]
clients = "{{root}}/clients"

[clients]
output = "{{ clients }}"
languages = ["rust", "typescript"]

[clients.rust]
output = "{{ clients }}/rust"

Source-only generation beside an existing SDK crate — no manifest is created or touched, so the handwritten SDK keeps full ownership of its Cargo.toml and entrypoint. The <program> directory level is always present, so embed the tree from the SDK’s lib.rs with a #[path = "lootbox_program/src/generated/mod.rs"] pub mod generated; declaration and re-export it:

[project]
program = "programs/lootbox"

[clients]
output = "{{root}}/sdks/rust"
languages = ["rust"]
scaffold = false

[clients.rust]
output = "."

This renders only sdks/rust/lootbox_program/src/generated/**; the SDK crate’s manifest must also declare the generated dependencies (Pina scaffolds them into a fresh Cargo.toml when scaffold = true, so generate once with scaffolding to copy the pin list).

A CLI-first project that also keeps the CPI crate for other programs to compose with:

[project]
program = "."

[clients]
output = "clients"
languages = ["cpi", "rust", "cli-rust"]
mode = "auto"

[clients.cli_rust]
scaffold = false

A migration-aware program with persisted answers and lint strictness raised for the canonical-dispatch rule. Its policy, for example pina migrations create --auto accounts,events --version-type u16, is recorded in migrations/manifest.json rather than here:

[project]
program = "."

[migrations.answers]
rename = ["value:points"]
assume_removed = []

[lints]
require_canonical_instruction_dispatch_for_idl = "deny"

Command-line overrides

pina generate accepts --output <DIR> (replaces clients.output, resolved from the current working directory), repeatable --client <LANGUAGE>, --mode <MODE>, and --no-scaffold. Per-run flags override the file for that invocation only; the committed configuration remains the source of record for teammates and CI.

Pina can also discover an existing, unambiguous Cargo package without pina.toml: defaults apply and clients land in <package>/clients. Add the file when a workspace contains multiple programs or when a team wants reproducible client selections.

Test a Program

pina test keeps two deliberately different feedback loops.

# Fast host tests, including Mollusk instruction tests.
pina test --unit

# Build the real SBF program and run the generated Surfpool test package.
pina test

# Select tests by name in either layer.
pina test --filter initialize
pina test --unit --filter rejects_wrong_owner

# Verify migration history and run historical compatibility cases.
pina test --compatibility

# Measure every instruction and record the result in compute-units.json.
pina test --record-compute-units

Default SBF workflow

The default command:

  1. discovers the program from --project or the current directory;
  2. builds the release bpfel-unknown-none artifact with bpf-entrypoint enabled;
  3. fails if the expected .so or tests/surfpool package is missing;
  4. publishes the artifact at target/deploy/<library-name>.so;
  5. sets PINA_SBF_ARTIFACT and runs the ignored test in tests/surfpool.

Projects created by pina init put the host-only pina_test dependency in a dedicated Cargo package under tests/surfpool. Its standalone workspace boundary isolates Surfpool’s host runtime from the program’s Mollusk dependencies and SBF build. pina_test owns the Surfpool 1.5 compatibility graph and exposes only the offline lifecycle, deployment, and instruction operations the generated test needs. The generated test starts an embedded, offline Surfnet on dynamic ports, deploys the explicit .so at a non-system declared program address, submits and confirms the starter Initialize instruction, and calls Surfnet::stop before returning. Surfnet also stops itself through Drop if an assertion panics. This avoids fixed ports, background daemon processes, readiness sleeps, leaked validator instances, and host SDK dependencies entering the on-chain artifact.

The embedded SDK is the correct fit for isolated integration tests. See Surfpool’s official SDK overview and installation guide.

Pina’s prebuilt CLI supports more operating systems and CPU targets than Surfpool currently publishes. On a machine where Surfpool or the SDK cannot run, pina test --unit remains available; the SBF integration command fails with the missing dependency instead of silently skipping it.

Unit mode

--unit runs cargo test without building SBF or requiring Surfpool. Pina intentionally leaves Cargo attached to the terminal in both test modes so filters, output, and interrupts behave like direct Cargo use. Keep pure logic and serialization tests native, and use Mollusk for fast instruction-level VM tests. Surfpool adds a real RPC boundary; it does not replace those faster layers.

Compatibility mode

pina test --compatibility verifies the checked-in ABI history and transition hashes before it runs the default SBF workflow. It sets PINA_COMPATIBILITY=1 for the Surfpool package. Use pina_test::compatibility_mode() to enable large historical fixture matrices.

Compatibility mode runs the complete Surfpool suite. Do not use the signal to skip current-flow tests.

Build historical cases with HistoricalAccount and HistoricalInstruction. These types preserve golden bytes from a released version. ProgramTest::install_historical_account installs old account data directly. ProgramTest::send_historical_instruction submits the old payload and positional account metas to the latest SBF artifact.

For a rejected migration, call ProgramTest::expect_historical_rejection_with_rollback. The helper accepts only a program-execution failure. It then compares each protected account with its exact pre-transaction state.

Recording compute units

Most transactions request the runtime’s default compute unit limit, and priority fees are charged per requested unit, so they pay for units they never use. Your Surfpool suite already runs every instruction against the real program, so it can measure what each one costs.

pina test --record-compute-units runs the complete Surfpool suite exactly as pina test does. While it runs, pina_test simulates each single-instruction transaction that ProgramTest::send, send_instruction, send_with_signers, or send_transaction submits to the program. When the whole suite passes, Pina writes compute-units.json beside the program’s Cargo.toml:

{
	"schemaVersion": 1,
	"measurement": "surfpool-simulation-max",
	"artifactSha256": "cd4f4344ca04f181016a16b7694b3ef10a93703e7c259c0be852807df8033614",
	"instructions": {
		"increment": {
			"computeUnits": 379,
			"samples": 5
		},
		"initialize": {
			"computeUnits": 1704,
			"samples": 6
		}
	}
}
  • Each instruction keeps the most compute units any successful sample consumed. A transaction that fails usually stops early, so failed samples never count.
  • Samples are attributed by the program’s full discriminator, whatever its width.
  • An instruction no successful test sent is left out, and the command names it. Its clients keep requesting the runtime default until a test exercises it.
  • artifactSha256 identifies the SBF build the suite measured. pina generate warns when target/deploy/<library-name>.so is a different build, so you know to record again.

The suite runs unfiltered: --record-compute-units conflicts with --unit and --filter, because a partial run would drop the measurements of the tests it skipped. A failing suite writes nothing.

The recording build never reads the existing compute-units.json, because the run replaces it. After you rename or remove an instruction, record again: a plain pina test, pina build, or pina idl warns about the stale entry and ignores it, and pina generate refuses to run until it is gone.

Commit compute-units.json with the clients it produced. The file is deterministic, so recording again without changing the program produces no diff. pina generate turns each measurement into the limit the generated clients request; see compute unit limits.

Options

OptionMeaning
--project <DIR>Project directory or a directory below it
--unitRun only native Rust and Mollusk tests
--compatibilityEnable historical fixtures in the complete SBF test suite
-f, --filter <FILTER>Pass a test-name filter to Cargo
--record-compute-unitsMeasure every instruction in the complete suite and write compute-units.json

Run pina test --help for the authoritative command contract.

Run a Development Surfnet

pina dev builds the current SBF program once, then delegates the persistent development network, artifact watching, and redeployment to Surfpool.

# First run: explicitly allow Surfpool to create txtx.yml.
pina dev --yes

# Review and commit txtx.yml, then use the safe offline default.
pina dev

# Opt into remote state explicitly.
pina dev --network devnet
pina dev --rpc-url https://api.mainnet-beta.solana.com

Pina invokes the foreground equivalent of:

surfpool start --watch --artifacts-path target/deploy \
  --manifest-file-path txtx.yml --offline

Surfpool remains in control of its terminal UI, logs, file watcher, redeployment, and shutdown. Pina intentionally leaves standard input, output, and errors attached to the foreground Surfpool process so prompts and Ctrl-C work normally. The short non-interactive version probe receives closed input and bounded output instead. Rebuild the program in another terminal to update the canonical .so and trigger redeployment.

Deployment runbook

Surfpool uses txtx.yml to describe deployment. Creating or refreshing that runbook can write project files, so Pina will not silently accept a missing manifest. Run pina dev --yes for the first invocation, then inspect and commit the generated txtx.yml. The flag is forwarded to Surfpool and authorizes its non-interactive runbook changes; omit it during ordinary development when you want Surfpool to ask before changing the runbook. Pina rejects directories, symlinks, and Windows reparse points at this path.

Network safety

Surfpool itself defaults to a mainnet datasource. Pina does not inherit that network-sensitive default: it always supplies --offline unless --network or --rpc-url is explicitly selected. The two upstream options conflict.

An explicit --rpc-url must use HTTP or HTTPS and include a host. Pina rejects user information, query parameters, fragments, and control characters. Surfpool receives every accepted URL as a child-process argument, where local process inspection can reveal it. Never put a credential or other secret anywhere in the URL, including an otherwise valid host or path. Prefer a named --network or a credential-free endpoint.

Pina requires Surfpool 1.5.0 or newer because its delegated --watch, --artifacts-path, and --runbook flags are tested against that contract. Pina’s own workspace pins and exercises Surfpool 1.6.0. See the official Surfpool CLI reference.

Options

OptionMeaning
--project <DIR>Project directory or a directory below it
--network <CLUSTER>Fork mainnet, devnet, or testnet
--rpc-url <URL>Fork a credential-free HTTP(S) RPC endpoint
--yesAllow non-interactive runbook file changes

Run pina dev --help for the authoritative command contract.

Generate and publish IDLs

pina idl owns the complete IDL lifecycle without adding another top-level command: generate a Codama document, compare it with canonical Program Metadata, fetch it, or publish an update.

Synopsis

pina idl [OPTIONS]
pina idl generate [OPTIONS]
pina idl fetch --cluster <CLUSTER> [OPTIONS]
pina idl diff --cluster <CLUSTER> [OPTIONS]
pina idl publish --cluster <CLUSTER> [OPTIONS]

Bare pina idl [OPTIONS] remains exactly equivalent to pina idl generate [OPTIONS]. Existing scripts do not need to change.

Generate a local IDL

OptionDefaultMeaning
-p, --path <DIR>.Program crate containing Cargo.toml and src/lib.rs.
-o, --output <FILE>stdoutWrite JSON to a file.
-n, --name <NAME>Cargo package nameOverride the emitted program name.
--compactoffEmit one-line JSON instead of pretty-printed JSON.

Output contract

Without --output, stdout contains JSON and nothing else. Progress and extraction counts go to stderr, so redirection is safe:

pina idl --path ./programs/counter_program > ./counter_program.json
jq . ./counter_program.json

With --output, the JSON is written to that file and stdout is unused:

mkdir -p ./idls
pina idl \
  --path ./programs/counter_program \
  --output ./idls/counter_program.json

The output file is replaced if it exists. Its parent directory must already exist.

What the extractor reads

The extractor starts at src/lib.rs and follows Rust mod declarations, including #[path = "..."] modules. Missing unconditional module files are errors. Missing modules behind #[cfg(...)] are treated as inactive because their configuration may not apply to the IDL build. It derives the public IDL from source shapes rather than requiring a separate schema file.

It recognizes:

  • #[instruction] payload structs and discriminator values;
  • #[account] state layouts;
  • #[error] enums;
  • #[pda] typed seed declarations;
  • #[derive(Accounts)] account order and mutability;
  • direct validation chains for signer, writable, address, owner, and PDA metadata;
  • canonical and grouped instruction dispatch arms.

Read Codama Workflow for supported dispatch shapes and source-authoring rules.

Program naming

By default, the Codama program name comes from package.name in the target Cargo.toml. --name changes the emitted name without changing the Rust package or source tree:

pina idl -p ./programs/counter_program --name counter_v2

Failure modes

The command exits unsuccessfully when the path is not a readable program crate, [package].name is absent, modules cannot be resolved, more than one source file defines process_instruction, declarations conflict, PDA attributes or validation links cannot be resolved, a supported schema cannot be represented safely, or JSON/output-file writing fails. Diagnostics include source or path context where available. These checks are fail-closed: the CLI does not silently omit malformed PDA metadata or choose one of several entrypoint dispatch sources.

For complete client generation, continue with pina generate.

Canonical Program Metadata

Pina publishes an IDL to Solana’s Program Metadata program with these fixed semantics:

  • seed: idl;
  • account type: canonical metadata;
  • authority: the deployed program’s upgrade authority;
  • source: direct, inline account data;
  • encoding: UTF-8;
  • compression: zlib;
  • format: JSON.

The canonical PDA is derived from the program address and the fixed idl seed. This gives each program one authoritative IDL. Pina does not expose third-party metadata, custom seeds, URL data, external-account data, immutability, or account closure in this initial workflow.

The adapter delegates transaction planning to the exact official npm package @solana-program/program-metadata@0.9.0. The behavior was cross-checked against upstream commit 33eb527e124cc4a09d8aae448cd306a9bd87db14. Pina does not duplicate the upstream allocation, reallocation, buffer, rent, or transaction-packing rules.

Runtime requirement

Network IDL commands require Node.js, npm, and an npx-compatible runner. By default Pina executes:

npx --yes @solana-program/program-metadata@0.9.0 ...

The package version is pinned, never latest. npx may download that pinned package if it is not already cached. Use --npx <COMMAND> only for a runner that accepts the same npx argument contract; it is not an arbitrary shell command. Pina constructs an argument vector and never invokes a shell.

Client stdout and stderr are drained concurrently into bounded buffers. Oversized output fails explicitly; Pina never truncates a transaction export and presents it as a valid plan.

Clusters and RPC safety

Every network operation requires --cluster. Accepted aliases are mainnet-beta, mainnet, devnet, testnet, localnet, and localhost. An HTTPS RPC origin may also be supplied. Loopback HTTP is accepted for local testing; remote plaintext HTTP is rejected.

Custom RPC URLs may not contain user information, query parameters, fragments, or a path. This is intentional: RPC API credentials placed in a URL would otherwise be exposed to the child process argument list. Configure an authenticated local proxy and pass its credential-free origin when a provider requires secrets.

Fetch

pina idl fetch \
  --cluster mainnet-beta \
  --program-id <PROGRAM_ADDRESS> \
  --output ./target/fetched/program.json

--program-id may be omitted inside a discoverable Pina project. Pina then generates the local IDL only to infer program.publicKey.

Fetch is deliberately fail-closed. Pina asks the official client for the raw stored bytes, then locally applies a bounded zlib decode, UTF-8/JSON parsing, complete Codama root-node deserialization, and target-program comparison. It does not follow URL or external-account metadata. Accounts using another otherwise-valid Program Metadata representation produce an actionable unsupported-content error instead of triggering an unexpected outbound request.

The decompressed IDL is capped at 16 MiB. --output is written atomically. Without it, stdout is the IDL JSON. --json wraps the document in a versioned result envelope for agents.

Semantic diff

pina idl diff --cluster devnet

pina idl diff \
  --cluster mainnet-beta \
  --program-id <PROGRAM_ADDRESS> \
  --file ./target/idl/program.json \
  --json

The comparison parses both documents as JSON. Object key order and whitespace do not matter; array order does. Exit statuses are:

StatusMeaning
0Local and canonical on-chain IDLs are semantically equal.
1Discovery, validation, RPC, decoding, or subprocess error.
2Both IDLs are valid, but their semantic values differ.

This makes pina idl diff --cluster <CLUSTER> --json suitable for deployment and release checks.

Publish directly

pina idl publish \
  --cluster devnet \
  --authority ~/.config/solana/upgrade-authority.json

Pina generates the project IDL unless --file is supplied. If --program-id is also supplied, it must equal program.publicKey inside that complete Codama document. Pina cryptographically checks that a keypair file’s public half matches its Ed25519 secret half, then gives the official client a stable private temporary copy so replacing the original path cannot change the signer after validation. Files larger than 4 KiB are rejected; on Unix, group or other permissions are also rejected and temporary copies use a 0700 directory with a 0600 file. Windows copies inherit the current user’s protected temporary-directory ACL; Windows keypair validation does not attempt to interpret arbitrary source-file ACLs. Key material is never printed.

--payer <KEYPAIR> separates transaction fees and metadata-account rent from the upgrade authority. When omitted, the authority also pays. --priority-fee <MICROLAMPORTS> defaults to 100000, matching the official client.

Interactive publication prints the cluster, program, and local IDL source, then requires the exact word publish. In CI, pass --yes. Export mode never submits and therefore does not require this confirmation.

The Program Metadata account must remain rent-exempt. Creation funds its 96-byte header and packed IDL content. Updates may transfer additional rent, extend the account in bounded steps, write through temporary buffers when a transaction cannot hold the content, trim unused bytes, and close temporary buffers. Those decisions belong to the pinned official planner.

Export for review or a multisig

Export every planned transaction without submitting anything:

pina idl publish \
  --cluster mainnet-beta \
  --authority ./upgrade-authority.json \
  --export \
  --output ./idl-plan.txt

For a Squads vault or another multisig, provide its public address instead of a secret keypair:

pina idl publish \
  --cluster mainnet-beta \
  --program-id <PROGRAM_ADDRESS> \
  --file ./target/idl/program.json \
  --export <MULTISIG_AUTHORITY> \
  --export-encoding base58 \
  --output ./idl-update.txt

An exported authority is a noop signer used only to plan the transaction messages. Do not pass --authority or --payer with --export <ADDRESS>; the multisig must authorize and pay when it imports the plan. The export contains every transaction in the upstream order, including allocation, buffer, write, initialization/update, trim, and cleanup transactions where required. Pina preserves the official [Transaction #N] framing rather than pretending the workflow is one transaction.

The default export encoding is base64. Use base58 when the receiving multisig requires it. Without --output, the complete export is written to stdout and Pina writes no status text there.

Exported transactions contain a recent blockhash. If approval takes too long, regenerate the export; do not submit a partially approved subset from an old plan.

Failure recovery

  • The authority is rejected: verify the deployed program is upgradeable and the supplied signer is its current upgrade authority. Pina never falls back to non-canonical metadata.
  • The process stops during a multi-transaction write: rerun pina idl diff first. If it differs, rerun the same publish command. The official planner inspects current state and creates an update plan; do not manually guess which write transaction was last confirmed.
  • An exported blockhash expires: discard the complete export and regenerate it.
  • A buffer transaction succeeds but a later transaction fails: retain logs and rerun publication. The upstream planner owns buffer lifecycle and recovery behavior.
  • Fetch reports unsupported content: the canonical account is not direct zlib/UTF-8 JSON. Use the official Program Metadata tooling to inspect it. Pina will not follow its URL or external account.
  • IDL program mismatch: regenerate from the correct program or pass the correct target. Never publish by editing only program.publicKey to bypass the check.
  • npx fails: confirm Node.js and npm are installed and that the pinned package can be resolved. Corporate/offline environments should pre-cache version 0.9.0 or supply an npx-compatible runner.

pina docs

List or render reference topics in the terminal.

Synopsis

pina docs [TOPIC]

Run without a topic to list bundled topics:

pina docs

The binary currently bundles:

TopicContents
pina-idlIDL extraction rules and supported Rust source shapes.
pina-overviewFramework concepts, crates, features, and workflows.
pina-validationDeclarative rules, manual alternatives, and code generation.

Render one with:

pina docs pina-idl
pina docs pina-overview
pina docs pina-validation

Markdown is rendered for the terminal rather than printed as raw source.

Each bundled topic is an mdt consumer of the repository’s templates/*.t.md providers, so docs:check fails when a topic and the template it mirrors disagree.

Custom topics

Set PINA_TEMPLATES_DIR to a directory containing <topic>.t.md files:

PINA_TEMPLATES_DIR=./team-docs pina docs deployment

For that example, Pina reads ./team-docs/deployment.t.md. A custom file takes precedence over a bundled topic with the same name. If no matching custom file exists, Pina falls back to the bundled topic.

Bare pina docs lists bundled topics only; it does not scan the custom directory. This keeps discovery deterministic even when the environment points at a large template tree.

Failure modes

An unknown topic returns a non-zero exit code and prints the bundled topic index. If PINA_TEMPLATES_DIR resolves a topic to a file that cannot be read, the command reports that path and fails.

pina keys

Inspect and manage the local program identity without exposing secret key bytes.

pina keys [OPTIONS] [COMMAND]

pina keys and pina keys show are read-only aliases. They discover the nearest Cargo program package, read its single declare_id!, and compare it with the conventional keypair at <cargo-target>/deploy/<lib-target>-keypair.json. Use --keypair to inspect a different local file and --json for automation.

Synchronize an existing keypair

pina keys sync
pina keys sync --keypair ./keys/counter-keypair.json

sync validates all 64 keypair bytes, derives the Ed25519 public key from the secret half, and refuses inconsistent files. It then parses Rust source and replaces only the string literal of exactly one declare_id!. Zero or multiple declarations fail before a write. Source identity and contents are checked again immediately before publication, so an editor save during synchronization fails instead of losing unrelated changes. The command never prints secret bytes.

Create or rotate identity

pina keys new
pina keys new --keypair ./keys/counter-keypair.json
pina keys new --force

new generates a cryptographically random Solana keypair, writes it with owner-only permissions on Unix, and synchronizes source. It refuses an existing keypair. --force is intentionally required to replace one and rotate the program identity. Windows generation currently fails before creating secret material because Rust’s safe standard API cannot construct a private ACL atomically; use a reviewed external keypair and pina keys sync instead.

Program deployments are identified by their address. Treat keys new --force as a destructive identity rotation: review downstream clients, deployment records, and funded accounts first.

The conventional keypair lives below the Cargo target directory, which pina init git-ignores, so cargo clean deletes it. Back it up outside the repository before the first deployment: it is the program’s address, and losing it after deployment means upgrades can no longer name the program by its keypair.

Identity and migration history

migrations/manifest.json records the program ID the history belongs to. When new or sync writes a different ID than the manifest records, the command prints the recorded ID and points at the next step:

  • If nothing was ever deployed (no publication receipt and no pending deployment), run pina migrations create. It rebinds the unpublished history to the new declare_id! and says so. This is the normal path after pina init, whose scaffold starts with a shared placeholder address.
  • If a receipt exists, the history belongs to the deployed program. Every migrations and deploy command fails with Migration history belongs to program … until the original declare_id! is restored; a new identity is a new program.

Output and failures

Human output identifies source, keypair path, public program IDs, and match status. --json emits only JSON on stdout. Failures are written to stderr and exit unsuccessfully.

Keypair inputs for show and sync must be regular, non-link files no larger than 4 KiB. Read-only selection may pass through an aliased ancestor. Generated keypair and source write destinations reject non-regular files, symbolic links, and reparse points anywhere in the destination path. The command also fails closed for malformed Rust, invalid addresses, ambiguous declarations, malformed JSON, incorrect keypair length, inconsistent secret/public halves, unavailable secure randomness, private-permission guarantees, and unauthorized replacement. Keypair and source publication use atomic replacement; failed source publication rolls the keypair back only while the generated file still has the identity created by the command. A concurrent keypair replacement is preserved and reported as a rollback failure.

pina doctor

Diagnose whether the current checkout is ready for Pina development.

pina doctor [OPTIONS]
pina doctor
pina doctor --path ./programs/counter
pina doctor --json

The report checks:

  • nearest Cargo program package, source entrypoint, and declared program ID, warning when it is still the shared pina init placeholder;
  • lint-driver resolution: the active toolchain, the release the shipped lints are verified against, the resolved driver with its own toolchain and how it was obtained, both search paths, and the remedy when nothing matched;
  • canonical SBF artifact and program-keypair paths;
  • source/keypair identity agreement;
  • required Rust/SBF tools (cargo, rustc, and the Agave cargo-build-sbf driver);
  • optional solana and surfpool tools;
  • Node.js plus an npx or pnpm renderer when configured clients require JavaScript tooling.

Human output is stable, color-free text. It includes typed check IDs such as project.discovery, project.program-id, project.artifact, lint.driver, and tool.surfpool so the same vocabulary appears in logs and agent output.

The Lint driver: section answers the one question a failing pina lint leaves open — which toolchain is active, which driver Pina resolved for it and which toolchain that driver was built for, and what to run when none resolved. pina doctor reports this state without downloading or building anything: a diagnostic that populates a cache cannot be run to find out what is wrong. A missing driver is a warning, not an error, because it blocks pina lint only and the project can still build and deploy.

Agent JSON

pina doctor --json > doctor.json

JSON is the only stdout content. Schema version 1 includes status, project, tools, typed checks, and actionable findings. Each check has a stable id, status (pass, warn, or fail), and message. The report never includes environment-variable values or keypair secret bytes.

Every external version or capability probe receives closed stdin, bounded output capture, and a five-second deadline. Pina attempts to terminate a tool that hangs and reports it as unavailable; it also stops waiting when a descendant keeps the tool’s output pipes open. Agent diagnostics therefore return within a predictable bound even when a probe misbehaves.

Warnings—including missing optional tools, an unbuilt artifact, a missing local keypair, or an unresolved lint driver—exit with code 0. Missing project discovery, unreadable program identity, or unavailable required Rust/SBF prerequisites produce status: "error" and exit with code 1; the JSON document is still emitted in full.

pina explain

Explain why a transaction to the current program failed.

pina explain <SIGNATURE> [--network localnet|devnet|testnet|mainnet | --rpc-url <URL>] [--project <DIR>] [--json]
pina explain --transaction-file <PATH> [--network ... | --rpc-url <URL>] [--project <DIR>] [--json]
pina explain 4MeRynoBFLczcN7jND2spt4Vvwn5K8kasDeG7vaJU7fjTDDA2YzwFkYUqzTGS4m5UQPFGEDbzqpTqncwwg1rVYVV
pina explain <SIGNATURE> --network devnet
pina explain --transaction-file ./failed.json --project ./programs/counter
pina explain <SIGNATURE> --json

A failed Pina check returns a bare error code, and several checks share one: writable, address, executable, data_len, and distinct_from all return InvalidAccountData. A distinct code per check would cost every program size and compute units, so Pina keeps the codes and moves the diagnosis off-chain. pina explain reads the failed transaction’s account flags, error, and logs, matches them against the program’s #[derive(Accounts)] structs and processors, and names the field and rule that most likely failed, with its path:line.

What it reports

Transaction 4MeRyno…VYVV (slot 62, from localnet)
Instruction #0 failed: check_policy (CheckAccounts) in validation_program
Error: InvalidAccountData

Most likely cause:
  audit: writable [confirmed, matches the program log] at src/lib.rs:221
    account #2 (EdmxWPmx2WH6WgFfTdu9xfkYf3k1g5wD1zccTVySEEh1) is read-only in this transaction

Accounts:
  #   field           signer  writable  address
  0   authority       yes     no        9hSR6S7WPtxmTojgo6GG3k4yDPecgJY292j7xrsUGWBu
  1   policy          no      no        GyGKxMyg1p9SsHfm15MkNUu1u9TN2JtTspcdmrtGUdse
  2   audit           no      no        EdmxWPmx2WH6WgFfTdu9xfkYf3k1g5wD1zccTVySEEh1
  3   system_program  no      no        11111111111111111111111111111111

Program logs (last 4 lines):
  …
  • The failing instruction, identified by its discriminator, with the accounts struct its dispatch parses.
  • The decoded error. Built-in runtime errors keep their names. Codes in Pina’s reserved range decode to their PinaProgramError variant, and other custom codes to the program’s #[error] variant with its first documentation line.
  • Ranked candidates: the checks whose error matches the observed one, each with its field, rule, location, and a confidence class. The program stops at its first failing check, so a check that runs after one the transaction proves failing is left out.
  • Construction sites: where the program writes the error (ValidationError::InvalidAmount, ProgramError::InvalidArgument), sites in the failing instruction’s accounts struct, instruction struct, processor, and validation hooks first.
  • The account table: each instruction slot with its field (parent.child for a nested struct, members[0] for a remaining slice), address, and the signer and writable flags the transaction gave it.
  • The failing instruction’s last log lines. Pina logs a message for the checks that share InvalidAccountData, such as account has not been marked as writable, and a candidate whose message appears is ranked first.

Confidence

ClassMeaning
confirmedProvable from the transaction alone: a missing signer or writable flag, too few or too many accounts, a mutable account repeated in a later slot, equal keys for distinct_from, or an address that differs from a known constant such as system::ID.
checked_against_current_stateowner, executable, empty, not_empty, and data_len evaluated against account state read after the transaction ran. The state may have changed since.
possibleCan return this error, but the check needs values pina cannot read offline: PDA seeds, value rules on instruction arguments, unresolved constants, or with hooks.

A rule whose validate(...) group sets error = ... is matched by that error’s code instead of the default.

Inputs and network

A signature is fetched with one getTransaction request at confirmed commitment. The default network is localnet (http://127.0.0.1:8899); Pina never queries mainnet unless --network mainnet or --rpc-url names it. When the failing instruction targets the explained program, one getMultipleAccounts request then reads the current owner, executable flag, and size of its accounts. Neither request is retried, and a failed state lookup becomes a note instead of an error.

--transaction-file reads a saved getTransaction result, or the complete JSON-RPC response around one, and works offline. Add --network or --rpc-url to also check state-dependent rules.

A transaction rejected by preflight simulation never lands, so getTransaction cannot find it. Send with preflight disabled, or explain a transaction captured from a test validator, when you need the landed failure.

--rpc-url accepts credential-free HTTP(S) URLs with a host. User information, query parameters, fragments, and control characters are rejected, plaintext http is accepted only for a loopback host, and redirects are not followed. Reports and errors name a custom endpoint as custom RPC endpoint rather than echoing its URL.

Limits

  • The failing instruction must target the program in --project. A failure in another program is reported with its decoded error and accounts, and nothing more.
  • A failure inside a CPI is attributed to the callee the logs name. The program’s own checks ran before the call, so no candidate is listed.
  • A field whose type is not a #[derive(Accounts)] struct of the program stops the account mapping at that field.
  • Processor checks are found by name on self.<field> (assert_signer, assert_owner, load_pda, and the rest), including through local aliases. A check behind a helper function is not attributed to a field, but its error constructions are still listed.
  • Writability demoted by the runtime, for example a reserved account marked writable, is read as the message declares it.

Exit status and JSON

A produced explanation exits with code 0, including for a transaction that succeeded, which prints that there is nothing to explain. An invalid signature, file, or URL, a project that cannot be parsed, a transaction that cannot be fetched or found, and a malformed transaction exit with code 1. Conflicting or missing transaction sources are usage errors with code 2.

pina explain <SIGNATURE> --json > /tmp/pina-explain.json
jq -e '.candidates[0].confidence' /tmp/pina-explain.json

JSON is the only stdout content. Schema version 1 contains signature, slot, source (file or the endpoint label), program, status (succeeded or failed), failure (instruction index, program, instruction, decoded error with kind, and cpi), ranked candidates, errorSites, accounts, logs, and notes. Each candidate has scope (account, argument, or instruction), field, accountIndex, rule, location, confidence, logConfirmed, and reason. Operational failures print only the error to stderr.

pina completions

Generate a completion script from the authoritative Clap command tree.

pina completions <SHELL>

Supported shells are Bash, Elvish, Fish, PowerShell, and Zsh. The script is written to stdout without progress text:

pina completions bash > ~/.local/share/bash-completion/completions/pina
pina completions zsh > ~/.zfunc/_pina
pina completions fish > ~/.config/fish/completions/pina.fish

Regenerate the script after upgrading Pina so new commands, options, and help text are available to the shell.

pina profile

Estimate compute-unit costs in a compiled Solana SBF shared object without starting a validator, or measure them line by line from the project’s Mollusk tests with pina profile trace.

Synopsis

pina profile [OPTIONS] [PROGRAM.SO] [COMMAND]
InputDefaultMeaning
PROGRAM.SOdetectedCompiled SBF ELF shared object.
--project <DIR>.Start directory for artifact discovery.
--jsonoffEmit structured JSON instead of a text table.
-o, --output <FILE>stdoutWrite the selected format to a file.

Examples

pina profile
pina profile --project ./programs/counter_program
pina profile ./target/deploy/counter_program.so
pina profile ./target/deploy/counter_program.so --json
pina profile ./target/deploy/counter_program.so --json --output ./profile.json

The positional path remains supported for scripts and custom artifacts. When it is omitted, Pina discovers the nearest Cargo program and profiles the canonical <cargo-target>/deploy/<lib-target>.so artifact. Discovery fails with the exact expected path when the program has not been built.

JSON includes program and binary metadata, aggregate instruction/syscall/CU counts, and a per-function array with offsets, sizes, and estimates.

Function names

Function names are demangled Rust paths without the legacy ::h<hash> suffix, so a function keeps its name across rebuilds and compare can match it. The deployed artifact is stripped down to its exported symbols, so when the profiled file sits in a directory directly below the Cargo target directory (such as target/deploy/), Pina reads function names from the unstripped linker output cargo build-sbf leaves at target/sbpf-solana-solana/release/<lib-target>.so. That file is only used when its .text section is byte-identical to the profiled one. Without it, the report falls back to the exported symbols, which is usually just entrypoint. A release profile with strip = true strips the intermediate too.

Estimation model

The profiler reads the ELF text section, decodes SBF instructions, discovers functions, and applies the repository’s static cost model. Regular instructions cost 1 estimated CU and recognized syscalls cost 100 estimated CU.

This is a deterministic comparison tool, not a replacement for runtime measurement. It cannot model data-dependent branches, invocation frequency, account state, CPI behavior, or runtime syscall variation. Use it to compare binaries and identify large functions, then validate important paths in an SVM or validator.

Comparing against a baseline

pina profile compare answers “what changed between these two builds?” by profiling the current artifact and diffing it against a saved report.

pina profile compare <BASELINE> [PROGRAM.SO] [OPTIONS]
InputDefaultMeaning
BASELINErequiredSaved profile report used as the baseline.
PROGRAM.SOdetectedCompiled SBF ELF shared object for the current build.
--project <DIR>.Start directory for artifact discovery.
--jsonoffEmit a stable, ordered JSON comparison document.
--fail-cu <CU>500Absolute total-CU increase that fails the comparison.
--fail-percent <PERCENT>10Percentage total-CU increase that fails the comparison.

Capture a baseline before changing the program, rebuild, then compare:

pina profile --json --output ./profile.json
# ... change and rebuild the program ...
pina profile compare ./profile.json
pina profile compare ./profile.json --json
pina profile compare ./profile.json --fail-cu 100 --fail-percent 5

The baseline may be any report written by pina profile --json --output, or a versioned baseline document that carries an explicit schema_version marker. Baselines declaring a different schema version are rejected instead of silently misread.

The text summary prints total deltas, then per-function deltas sorted by absolute CU change. Functions are matched by symbol name; added and removed functions are reported separately. The summary line classifies the build as unchanged, improved, a small regression, or a threshold regression.

Exit status

CodeMeaning
0Comparison completed without a threshold regression.
1Operational error: unreadable or malformed baseline, profiling failure.
2The total CU regression reached both --fail-cu and --fail-percent.

Both limits must be reached, mirroring the failure policy of the repository’s CI compute-unit workflow, so a local pina profile compare reproduces the CI gate with the default thresholds.

JSON comparison document

--json emits a deterministic document with schema_version, baseline and current program names, total snapshots and deltas, the applied threshold, an overall status (unchanged, improved, regression, or threshold-regression), and a functions array sorted by delta magnitude. Identical inputs always produce identical bytes, so the output is safe to diff or store.

Tracing executed compute units

pina profile trace answers “where did the compute units of this instruction go, line by line?” by measuring instead of estimating. It builds the program, runs the project’s Mollusk tests with register tracing, and attributes every executed SBF instruction to a source line and a call stack.

pina profile trace [OPTIONS]
InputDefaultMeaning
--project <DIR>.Start directory for project discovery.
--filter <TEST>allRun only tests whose names contain TEST, as cargo test <TEST> does.
--instruction <NAME>allReport one instruction, in any case style, or a label such as increment #2.
--trace-dir <DIR>noneAnalyze existing traces without building or running tests.
--jsonoffEmit the versioned JSON document.
--foldedoffEmit folded stacks for speedscope, inferno, or flamegraph.pl.
-o, --output <FILE>stdoutWrite the selected output to a file.
pina profile trace
pina profile trace --project ./programs/counter_program
pina profile trace --filter increment --instruction increment
pina profile trace --json --output ./trace.json
pina profile trace --folded > ./stacks.folded
pina profile trace --trace-dir ./target/pina/trace/traces

Setup

Mollusk records traces only when its register-tracing feature is enabled, so declare the program’s dev-dependency with it:

[dev-dependencies]
mollusk-svm = { version = "0.15", features = ["register-tracing"] }

Tests must load the program by name (for example Mollusk::new(&program_id, "my_program")), so the run can point SBF_OUT_DIR at the traced build. A copy of the program in tests/fixtures/ takes precedence over SBF_OUT_DIR and is reported as traces from another build. When no trace is recorded, the error shows the exact dependency line for the program’s manifest.

What it runs

  1. cargo build-sbf with the production size profile pina build uses, plus CARGO_PROFILE_RELEASE_DEBUG=line-tables-only and CARGO_PROFILE_RELEASE_STRIP=none. The stripped program goes to <target>/pina/trace/build/, where the tests load it, and the unstripped linker output with DWARF is copied to <target>/pina/trace/<lib-target>.so.debug for attribution. Mollusk cannot load an ELF whose symbol table holds long Rust names while tracing, which is why the two are separate.
  2. The same build without debug information into <target>/pina/trace/release/, to check whether debug information changed code generation.
  3. cargo test --manifest-path <program>/Cargo.toml [TEST] with SBF_OUT_DIR and SBF_TRACE_DIR=<target>/pina/trace/traces set. Test output goes to stderr so --json and --folded keep stdout machine-readable. A failing test run exits with the test runner’s status; the traces recorded before the failure stay in the trace directory for --trace-dir.

--trace-dir skips all three steps and attributes the traces with the traced build from the project’s last run.

Reading the results

Every executed SBF instruction costs 1 CU, so the trace counts exactly what the program executed. The runtime charges syscalls separately and those charges are not in the trace: each syscall is listed by name, invocation count, and calling line, and only its 1-CU call instruction is counted.

Recordings that followed exactly the same instruction path are reported once, with every trace id. A profile is named after the program instruction whose discriminator it loaded from the instruction data (the loader passes its address in r2), and otherwise trace <id>. Profiles are ordered by name and then by cost, and repeated names get a #2 suffix, so identical recordings always produce identical output.

  • Lines are the innermost source line of each instruction. Instructions the compiler attributed to line 0 are reported as having no line information.
  • Functions report self and inclusive cost. Code that LTO inlined into entrypoint is attributed to the function it was written in through DWARF’s inlined-subroutine records; inlined frames carry DWARF’s short names, such as assert_writable or get<u8>.
  • Stacks are physical call frames rebuilt from the trace’s call, callx, and exit instructions, each expanded into its inlined frames.

The text summary lists each profile’s ten most expensive lines, top functions, and syscalls, then prints the path of the HTML report at <target>/pina/trace/<lib-target>.html. The report is a single self-contained file with an instruction picker, a zoomable icicle chart of the call stacks, the hottest lines, and the sampled source files annotated with per-line cost. Source is read when the report is written. Paths of workspace members resolve against the workspace root; dependencies from a registry record paths relative to their own package (src/lib.rs), so their lines show numbers without source.

Debug information and code generation

Debug information can change SBF code generation, so the traced build is compared with the release build. When their .text sections differ the command warns with the number of differing instruction slots, and the JSON document records it under releaseBuild. Executed counts then describe the traced build and can differ slightly from the deployed program.

JSON trace document

--json emits a camelCase document with schemaVersion: 1: the program name, the SHA-256 of the traced executable, lineInfo, recordedTraces and skippedTraces, releaseBuild (or null), and an instructions array. Each instruction carries its name, the matched instruction and observed discriminator, traceIds, executedInstructions, syscallInvocations, unattributedInstructions, and lines, functions, syscalls, and stacks arrays.

Safety and failures

The command compares filesystem identity and refuses an output that is the input binary, a hardlink to it, or below a symbolic-link/reparse-point path. Reports are published atomically, so a failed write cannot truncate an existing destination or the input binary. Profiling also fails for unreadable files, invalid ELF data, binaries without an SBF text section, output creation errors, and JSON serialization errors. compare additionally fails closed for missing, unreadable, or malformed baselines, documents that are not profile reports, and unsupported baseline schema versions.

Rehearse an Upgrade

pina rehearse replays a deployed program’s real transactions against the binary you are about to ship, before you ship it. Every recent transaction runs twice on the same forked state, once against the deployed program and once against the candidate, and every difference in outcome, written account state, and compute units is reported.

# Rehearse the conventional artifact against the latest 25 devnet transactions.
pina rehearse --network devnet

# Build first, and replay a deeper sample of mainnet traffic.
pina rehearse --network mainnet --build --limit 100

# Rehearse an explicit binary against a custom endpoint.
pina rehearse --rpc-url http://127.0.0.1:8899 --program ./target/deploy/my_program.so

# Rehearse specific transactions instead of the latest ones.
pina rehearse --network devnet --signature <SIGNATURE> --signature <SIGNATURE>

Nothing is sent to the cluster and no project file is written. Pair it with pina migrations and pina deploy: migrations prove the new code can read old data, and a rehearsal proves it still behaves the same on the traffic your users actually send.

To gate the upgrade itself, run the rehearsal as part of the deployment with pina deploy --rehearse. It rehearses the exact artifact the deployment plan pins, against the cluster the deployment targets, and stops the deployment before anything is sent when behaviour changes or nothing could be compared.

How a rehearsal runs

  1. Fetch. Pina confirms the program exists on the cluster (getAccountInfo), so a program that was never deployed fails before any Surfpool starts. It then asks the cluster for the program’s most recent signatures (getSignaturesForAddress, at confirmed commitment) and fetches each transaction exactly as it landed. With --signature, only the named transactions are fetched.
  2. Fork. Pina starts a private Surfpool forked from the same RPC endpoint, on free loopback ports, inside a temporary directory, and waits up to a minute for it to answer. The fork is always stopped: on success, on error, on panic, and on Unix even when pina is interrupted or killed, because Surfpool runs under a small supervisor that stops it as soon as pina exits.
  3. Freeze. Every account the transactions touch is loaded into the fork in one pass. Accounts that do not exist are marked offline, so the remote is never consulted again: both runs see one frozen snapshot, even on a busy cluster.
  4. Baseline. Each signed transaction is profiled against the deployed program. Profiling executes on a throwaway copy of the fork, so nothing commits and every transaction sees the same snapshot.
  5. Install. The candidate is written into the program’s own program-data account, as an upgrade would write it: the same account and upgrade authority, with the old executable’s tail zeroed.
  6. Candidate. Each transaction is profiled again and compared with its baseline.

Two Surfpool behaviours shape the design:

  • Profiling validates blockhash age. Real traffic carries blockhashes that expired long ago and would fail with Blockhash not found, so the fork runs with --skip-blockhash-check. Signature verification stays on: only authentic signed transactions are replayed.
  • Surfpool hides failed program loads. When it cannot load an ELF, it logs the error and keeps executing the previously cached program, so a broken candidate could otherwise rehearse as “unchanged”. Pina confirms every program swap: it rewrites the program account with one extra lamport, which the runtime only stores after loading the ELF, and reads it back. A candidate the runtime rejects fails the rehearsal, because an upgrade to that ELF would be rejected the same way.

The fork’s clock never advances (--block-production-mode manual), so programs that read the Clock sysvar see the same time in both runs.

Reading the report

Each transaction gets one status:

StatusMeaningFails the rehearsal
unchangedIdentical outcome, written account state, and compute unitsNo
cu_changedIdentical outcome and state; compute units differNo
state_changedBoth runs succeed but leave a writable account differentYes
outcome_changedOne run fails and the other succeeds, or both fail with different errorsYes
skippedNot compared, with a reason (failed_in_both, unavailable, and others)No

Both runs execute the same signed transaction on the same snapshot, so any difference belongs to the binaries. In particular:

  • A transaction that fails identically in both runs is skipped (failed_in_both). This is the common case for older traffic: an initialize whose account now exists, or a transfer from an account that has since been drained. It says nothing about the upgrade, so it is listed and never counted as a regression.
  • A transaction that fails in both runs with different errors is an outcome change. Error codes are part of a program’s observable contract; clients and other programs branch on them.
  • A transaction that failed in the baseline but succeeds with the candidate is an outcome change. The new code accepts something the deployed code rejects, which deserves review before it ships.
  • A transaction Surfpool refuses to run is skipped (not_profiled) only when both runs refuse it identically. Surfpool refuses before the program executes, while it verifies signatures and loads accounts and lookup tables, so a refusal cannot depend on the binary: the same refusal twice comes from the forked state, such as a lookup table closed since. A refusal in only one run, or a different one in each, can only be the environment failing, and stops the rehearsal with exit code 1.

For state_changed transactions the report lists every writable account whose final state differs: lamports, owner, data length, and the data itself. For accounts the program owns, the account type is matched by discriminator and the differing bytes are decoded field by field from the project’s IR, baseline value first:

2y8JkzTSum83V1nSkpy64WHZv1S8pqWFMJ8YRxP3pqtqgwTBdyKGMwZ1rBsg4auwogTkpoJunSjxcBnj8XG4EECP  state_changed  [increment]
  compute units 473 -> 473 (0)
  account 14P1xLXPqLmxipH1tno2oKiS9GQPZJosNuK1AJPe4Ffn (CounterState)
    count: 4 -> 5

Fixed PinaPod layouts decode integers, booleans, addresses, and floats, and the discriminator and migration-version header are named too. Compact layouts, and bytes no field covers, are reported as byte ranges. Account differences are computed only when both runs succeed, because a failed run commits no state. For outcome changes the report shows the end of each run’s logs instead.

The compute-unit table covers transactions that succeeded in both runs, per instruction of your program: minimum, median, and maximum for each binary, and the change in the median. The median of an even sample is the lower middle value.

Exit status

CodeMeaning
0At least one transaction was compared and none changed outcome or state. Compute-unit changes alone pass.
2At least one state_changed or outcome_changed transaction, without --allow-changes
3No transaction was compared: every one was skipped, or there was no traffic. Nothing was verified.
1Operational error: invalid input, missing Surfpool, a program that is not deployed, an RPC failure, a candidate the runtime rejects. Stdout is empty.

This follows the CLI’s convention for comparisons (pina profile compare, pina idl diff, pina verify check): 2 means the comparison completed and found a difference. Clap also exits 2 for invalid arguments, but then prints usage to stderr and nothing to stdout. Use --allow-changes once the differences are reviewed and intended.

A rehearsal that compares nothing is never a pass, so 0 always means the upgrade was exercised. With exit code 3 the report still prints, and its skipped section explains each transaction. --allow-changes does not change it: there are no changes to accept. Rehearse more or newer traffic with --limit or --signature, or treat 3 as acceptable in automation for a program that has no traffic yet.

JSON report

--json prints one stable document with camelCase keys. Progress lines go to stderr, so stdout stays a single document.

{
	"schemaVersion": 1,
	"cluster": "devnet",
	"programId": "GJQcuWrT2f3f4KNuJcXhhwUa1ZQTYbxzzJ1hotzKu8hS",
	"deployedSha256": "1e62…",
	"candidateSha256": "d380…",
	"slot": 2001,
	"summary": {
		"total": 4,
		"unchanged": 0,
		"cuChanged": 0,
		"stateChanged": 3,
		"outcomeChanged": 0,
		"skipped": 1
	},
	"instructions": [
		{
			"name": "increment",
			"samples": 3,
			"baseline": { "min": 473, "median": 473, "max": 473 },
			"candidate": { "min": 473, "median": 473, "max": 473 },
			"medianDelta": 0
		}
	],
	"transactions": [
		{
			"signature": "2y8J…",
			"status": "state_changed",
			"skip": null,
			"instructions": [
				{ "name": "increment", "baselineUnits": 473, "candidateUnits": 473 }
			],
			"baseline": { "error": null, "computeUnits": 473 },
			"candidate": { "error": null, "computeUnits": 473 },
			"accounts": [
				{
					"address": "14P1…",
					"accountType": "CounterState",
					"baseline": { "lamports": 967440, "owner": "GJQc…", "dataLen": 11 },
					"candidate": { "lamports": 967440, "owner": "GJQc…", "dataLen": 11 },
					"fields": [{ "name": "count", "baseline": "4", "candidate": "5" }],
					"byteRanges": [],
					"omittedByteRanges": 0
				}
			],
			"logs": null
		}
	]
}

Every key is always present; absent values are null. status and skip.reason use the snake_case spellings shown in the status table. Executable hashes are SHA-256 with trailing zero padding removed, matching solana-verify, so a deployed program’s zero-padded program data and its local .so hash the same. The schemaVersion changes only with a breaking change to the document.

Requirements and limits

  • Surfpool 1.6.0 or newer must be on PATH, or named by PINA_SURFPOOL. Pina checks the version before reading the project.
  • On Windows, Surfpool runs as a direct child: it is stopped on every normal exit path, but a console interrupt that terminates pina while Surfpool is still starting can leave it running.
  • The deployed program must use the upgradeable loader, as solana program deploy and pina deploy do.
  • The program ID is the project’s declare_id!. The candidate defaults to <cargo-target>/deploy/<library-name>.so; --program names another file and --build runs the same build as pina build first.
  • Transactions replay against the cluster’s current state, not the state they originally saw, and each one sees the same snapshot: state written by one is not visible to the next.
  • Legacy and v0 messages are supported. A transaction the RPC no longer returns is skipped as unavailable; one it cannot return in a supported version is skipped as undecodable.
  • --limit accepts 1 to 1000, a single getSignaturesForAddress page.

Network safety

Pina only reads from the cluster: one getAccountInfo request for the program, one getSignaturesForAddress request, and one getTransaction request per transaction, each sent exactly once with a timeout. A failed request stops the rehearsal with exit code 1 instead of being retried, so a rate-limited endpoint is not hammered; use a dedicated RPC endpoint or a smaller --limit when a public endpoint refuses. That includes JSON-RPC errors returned inside an HTTP 200 response, such as an unhealthy node (-32005) or a provider’s rate limit. The one exception is -32015, the RPC’s answer for a transaction it cannot encode in a supported version: that transaction is skipped as undecodable. Redirects are not followed. The fork fetches each account the transactions touch from the same endpoint once.

A custom --rpc-url must use HTTP or HTTPS with a host. Pina rejects user information, query parameters, fragments, and control characters. Surfpool receives the URL as a child-process argument, where local process inspection can reveal it, so never put a secret anywhere in the URL, including its path. Reports show only the URL’s origin.

Options

OptionMeaning
--project <DIR>Project directory or a directory below it
--network <CLUSTER>Rehearse against mainnet, devnet, or testnet
--rpc-url <URL>Rehearse against a credential-free HTTP(S) RPC endpoint
--program <PROGRAM.SO>Candidate binary; conflicts with --build
--buildRun the pina build build first and rehearse its artifact
--limit <N>Recent transactions to replay, 1 to 1000 (default 25)
--signature <SIG>Rehearse this transaction instead; repeatable
--jsonPrint the stable JSON report
--allow-changesExit 0 even when behaviour changed

Run pina rehearse --help for the authoritative command contract.

pina deploy

pina deploy resolves and validates a Pina program deployment, displays the complete operation, and delegates the write to the Solana CLI. It never inherits a cluster from Solana configuration.

pina deploy [OPTIONS] --upgrade-authority <KEYPAIR> --payer <KEYPAIR> \
  --cluster <CLUSTER|URL>

Run pina deploy --help for the authoritative option list.

Rehearse first

Before upgrading a program that already has users, pass --rehearse. After printing the plan and before the confirmation prompt, Pina replays the target cluster’s recent transactions against the deployed program and the planned artifact on a private Surfpool fork, exactly as pina rehearse does, and prints the rehearsal report below the plan:

pina deploy --build --rehearse --cluster devnet \
  --upgrade-authority ./keys/devnet-authority.json \
  --payer ./keys/devnet-payer.json

Nothing is sent while the rehearsal runs, and its result decides whether anything is sent at all:

Rehearsal resultpina deploy
At least one transaction compared, no outcome or state changedContinues to confirmation and deployment. Compute-unit changes are informational.
A transaction’s outcome or written account state changedStops with exit code 2. --allow-rehearsal-changes deploys once you have reviewed them.
No transaction could be comparedStops with exit code 3, even with --allow-rehearsal-changes. Nothing was verified.
The program is not deployed on the target yetStops with exit code 3 before Surfpool starts. Deploy a first version without --rehearse.
The rehearsal cannot run: Surfpool missing or too old, an RPC failure, …Stops with exit code 1.

A rehearsal also proves the runtime loads the new ELF, so an upgrade the cluster would reject fails before anything is sent.

The rehearsal replays the cluster the deployment writes to. A named cluster rehearses against its public endpoint (localnet is http://127.0.0.1:8899). A custom URL is rehearsed through the same endpoint, after the URL checks pina rehearse --rpc-url applies, and the report names it by its origin only. --rehearse-limit <N> replays the N most recent transactions, from 1 to 1000 (default 25). --rehearse-limit and --allow-rehearsal-changes require --rehearse.

The rehearsed bytes are the deployed bytes. Pina reads the planned artifact once, checks it against the SHA-256 fingerprint the plan pinned, and rehearses that copy; with --build, that is the artifact the build just produced. It also refuses a project whose declare_id! no longer matches the planned program ID. The deployment later checks its private snapshot against the same fingerprint (see Build and external requirements), so an artifact replaced during or after the rehearsal stops the deployment instead of reaching the cluster.

With --dry-run, a rehearsal reports what the deployment would do and exits with the same codes, so CI can gate an upgrade without deploying it. With --dry-run --json, the plan object gains a rehearsal key holding the complete pina rehearse --json report. Rehearsal progress goes to stderr in every mode. A rehearsal stopped by a first deployment or an operational error prints no report, and with --json no document.

Safe planning

Review a deployment without building, contacting an RPC endpoint (unless --rehearse replays its traffic), or invoking the Solana CLI:

pina deploy \
  --project ./programs/counter_program \
  --cluster devnet \
  --upgrade-authority ./keys/devnet-authority.json \
  --payer ./keys/devnet-payer.json \
  --dry-run

Use --dry-run --json for agents and CI. The plan includes the canonical project, artifact, program keypair, declared program ID, upgrade authority, fee payer, cluster, complete RPC endpoint, acknowledgement policy, and ordered command argument vector. Custom URL hosts and paths are intentionally preserved, so they must not contain secrets.

Dry runs perform no build or deployment. Consequently, --dry-run conflicts with --build, --yes, and --allow-mainnet.

Input resolution

--project accepts a project directory or any directory below it. Pina uses the same project discovery as pina build: the nearest ancestor pina.toml, then unambiguous Cargo metadata as a fallback. The library target name and Cargo metadata target directory resolve:

  • <cargo-target>/deploy/<lib-name>.so
  • <cargo-target>/deploy/<lib-name>-keypair.json

Use --program or --program-keypair to override either conventional path. --program conflicts with --build. Every resolved file is canonicalized and must be a regular file. Keypair files cannot exceed 4 KiB and must contain a valid 64-byte Solana JSON keypair. On Unix, Pina rejects keypairs with any group or world permission bits; chmod 600 <keypair> is the recommended mode. Pina cannot inspect equivalent Windows ACL policy, so Windows operators must restrict keypair access with the operating system’s ACL tooling. The program keypair address must match the program’s declare_id!; deploy never generates or silently replaces an identity.

--upgrade-authority and --payer are always explicit. Pina does not inherit wallet paths from Solana configuration and does not store deployment credentials in pina.toml.

Targets and confirmation

One explicit target is required:

  • --cluster localnet
  • --cluster devnet
  • --cluster testnet
  • --cluster mainnet-beta
  • --cluster <HTTP(S)-URL>

Custom endpoints reject URL user information, query parameters, and fragments. The Solana CLI accepts its RPC endpoint through --url, so every accepted host and path is visible in the dry-run plan, confirmation, and operating-system process listings. Pina cannot distinguish a provider token embedded in a path from a legitimate endpoint path. Never put a secret anywhere in a custom URL; prefer a named cluster when possible.

Local endpoints execute after displaying the plan. Every remote endpoint prompts the operator to type deploy. Non-interactive remote deployment fails before starting a child process unless --yes is supplied. Named mainnet and every custom remote endpoint additionally require --allow-mainnet, because Pina cannot prove which Solana cluster an arbitrary URL serves. The flag is rejected for localnet, devnet, testnet, and custom loopback endpoints.

Local means the parsed host is a loopback address (localhost, 127.0.0.0/8, or [::1]), so spellings such as localhost.example.com, 127.0.0.1.nip.io, or http://127.0.0.1@example.com are classified as remote or rejected. A loopback port is still not proof of a local cluster: an SSH tunnel or proxy that forwards 127.0.0.1:8899 to a live cluster would skip confirmation and record no publication receipt. Deploy to a forwarded cluster through its real URL instead.

Build and external requirements

Pina validates and redacts the explicit target before project discovery or any build begins. --build then invokes the same in-process project build workflow as pina build before Pina resolves and displays the final deployment plan. It does not search PATH for another Pina executable. A failed build stops immediately.

After confirmation, Pina copies the artifact and every keypair into a private, owner-only temporary directory (pina-deploy-*, mode 0700, keypairs 0600), revalidates the copies against the declared program ID and the SHA-256 fingerprints taken at planning time, and hands the child those copies rather than the original paths. A file replaced after the plan was displayed is therefore detected, and one replaced after the final check cannot reach the child. The displayed plan shows the paths you passed; the child’s argument vector names the snapshot copies.

Without --remote-command, deployment requires the external solana executable from Agave on PATH; a custom deploy command does not. Pina runs every modeled command from the resolved project root, passes an argument vector directly rather than a shell command string, and closes the child’s standard input after Pina handles confirmation. The npm-distributed Pina binary supports platforms on which Agave may not be available, so verify the local Agave installation before depending on deployment automation.

Custom deploy commands

--remote-command <COMMAND> replaces solana program deploy with sh -c <COMMAND> (cmd /C <COMMAND> on Windows), for platforms that deploy through their own tooling. Every other safeguard still applies: target policy, confirmation, snapshot validation, and publication receipts. The deployment facts reach the command only as environment variables, never interpolated into the command string:

VariableValue
PINA_DEPLOY_RPC_URLthe normalized RPC URL
PINA_DEPLOY_CLUSTERthe named cluster or custom
PINA_DEPLOY_PROGRAMthe snapshot copy of the SBF artifact
PINA_DEPLOY_PROGRAM_IDthe declared program ID
PINA_DEPLOY_PROGRAM_KEYPAIRthe snapshot copy of the program keypair
PINA_DEPLOY_UPGRADE_AUTHORITYthe snapshot copy of the upgrade authority
PINA_DEPLOY_PAYERthe snapshot copy of the fee payer

Quote the variables inside the command ("$PINA_DEPLOY_PAYER"), and treat an exit status of zero as the command’s claim that the deployment succeeded: Pina records the publication receipt on that claim alone.

Migration publication receipts

For a program with checked-in migrations, a deployment to any non-loopback target freezes the ABI versions it ships. Before starting the deploy program, Pina writes a pending record to migrations/publications.json; after the program succeeds, it appends that record as a receipt pinning the schema of every version it shipped. From then on pina migrations create advances those contracts to a new version instead of rewriting them. --record-publication opts a loopback deployment into the same lifecycle, which is how a Surfpool run exercises it.

If the deploy program starts and then fails, the pending record stays, because the program may already be live. Rerunning the exact same deployment reconciles it, and pina migrations reconcile explains the state. If the deploy program never started, for example because solana is not installed, Pina discards the pending record it just wrote and says so, because nothing can have reached the cluster. A pending record left by an earlier attempt is never discarded this way.

Output contract

Without --json, stdout contains the plan, the rehearsal report with --rehearse, and completion status. Diagnostics, confirmation, and child failures use stderr. --dry-run --json emits one JSON object to stdout and no progress text. The full accepted RPC host and path appear in both formats and in the Solana argument plan. Missing files, malformed keypairs, program-ID mismatches, ambiguous projects, invalid RPC URLs, rejected confirmations, missing executables, signaled children, and non-zero child exits all fail closed.

Automation and Agent Usage

The CLI help and stream contracts are designed to let an agent discover capabilities without reading repository source.

Discovery protocol

Use this sequence before constructing a command:

pina --version
pina --help
pina <command> --help

For client generation, inspect the command and its ecosystems:

pina generate --help

For deployment verification, inspect the group and selected leaf:

pina verify --help
pina verify check --help
pina verify record --help

For project testing and a persistent local network:

pina lint --help
pina test --help
pina dev --help
pina rehearse --help

For project diagnostics, failed transactions, and identity:

pina doctor --help
pina explain --help
pina keys --help

For write-lock contention between instructions, and its interactive map:

pina locks --help
pina map --help

For framework and extractor constraints:

pina docs
pina docs pina-overview
pina docs pina-idl

Do not infer flags from examples alone. The command-specific --help output is the authoritative interface and includes defaults, output routing, requirements, and examples.

Machine-readable workflows

Generate and validate an IDL through stdout:

pina idl --path ./programs/counter_program --compact > /tmp/counter.json
jq -e . /tmp/counter.json

Write directly to a known artifact path:

mkdir -p ./artifacts
pina idl \
  --path ./programs/counter_program \
  --output ./artifacts/counter.json

Profile a binary as JSON:

pina profile ./target/deploy/counter_program.so --json > /tmp/profile.json
jq -e '.functions | type == "array"' /tmp/profile.json

Trace executed compute units from the project’s Mollusk tests as JSON. Build and test output goes to stderr, so stdout stays a single JSON document; a failing test run exits with the test runner’s status:

pina profile trace --json > /tmp/trace.json
jq -e '.schemaVersion == 1 and (.instructions | length > 0)' /tmp/trace.json
jq '.instructions[] | {name, executedInstructions, syscalls}' /tmp/trace.json

Diff the current build against a saved baseline; the exit status is 2 when a total CU regression reaches both --fail-cu and --fail-percent:

pina profile compare /tmp/profile.json --json > /tmp/comparison.json
jq -e '.status == "unchanged" or .status == "improved"' /tmp/comparison.json

Rehearse an upgrade against recent traffic. Gate on the exit status first: it is 0 only when at least one transaction was compared and none changed its outcome or written account state, 2 when behaviour changed, 3 when nothing could be compared, and 1 for an operational failure with empty stdout. A JSON check must require the same three conditions, so a report whose only changes are account state, or that compared nothing, never passes:

pina rehearse --network devnet --json > /tmp/rehearsal.json
jq -e '.schemaVersion == 1 and .summary.total > .summary.skipped and .summary.stateChanged == 0 and .summary.outcomeChanged == 0' /tmp/rehearsal.json

Report write-lock contention through the versioned JSON contract, and gate CI on hotspots that [locks] allow does not accept:

pina locks --json > /tmp/pina-locks.json
jq -e '.hotspots | map(select(.allowed | not)) | length == 0' /tmp/pina-locks.json
pina locks --deny-hotspots

Read the program map’s data, the locks document plus per-instruction and per-account-type detail, without writing its HTML page:

pina map --json > /tmp/pina-map.json
jq -e '.instructionDetails | length > 0' /tmp/pina-map.json

Diagnose project readiness through the versioned JSON contract:

pina doctor --json > /tmp/pina-doctor.json

Read-only identity inspection is also JSON-safe:

pina keys show --json > /tmp/pina-keys.json

Explain a failed transaction through the versioned JSON contract. A saved getTransaction result works offline:

pina explain --transaction-file ./failed.json --json > /tmp/pina-explain.json
jq -e '.status == "succeeded" or (.candidates | type == "array")' /tmp/pina-explain.json

These commands write no progress or ANSI styling to stdout. Doctor check IDs and statuses are stable agent inputs; do not parse the human report when JSON is available.

Inspect a deployment without executing a child process:

pina deploy \
  --project ./programs/counter_program \
  --cluster devnet \
  --upgrade-authority ./keys/devnet-authority.json \
  --payer ./keys/devnet-payer.json \
  --dry-run --json > /tmp/deploy-plan.json
jq -e '.program_id and .commands' /tmp/deploy-plan.json

Rehearse the planned upgrade in the same step. --rehearse replays the target cluster’s recent traffic against the exact artifact the plan pins and adds the pina rehearse --json report under a rehearsal key; the plan’s own keys are unchanged. Gate on the exit status first: 0 when the rehearsal compared at least one transaction and none changed, 2 when behaviour changed, 3 when nothing could be compared or the program is not deployed yet (no document is printed for a first deployment), and 1 for an operational failure:

pina deploy \
  --project ./programs/counter_program \
  --cluster devnet \
  --upgrade-authority ./keys/devnet-authority.json \
  --payer ./keys/devnet-payer.json \
  --rehearse --dry-run --json > /tmp/deploy-plan.json
jq -e '.rehearsal.schemaVersion == 1 and .rehearsal.summary.total > .rehearsal.summary.skipped and .rehearsal.summary.stateChanged == 0 and .rehearsal.summary.outcomeChanged == 0' /tmp/deploy-plan.json

Automation rules

  • Check the exit status before consuming output.
  • Run pina lint before review; use --fix only when working-tree edits are authorized and always inspect the diff.
  • Treat stderr as diagnostics and progress, not as part of IDL JSON.
  • Use explicit paths; relative paths depend on the process working directory.
  • Create the parent of an idl --output file before invoking the command.
  • Treat Codama output roots as replaceable generated directories.
  • Use repeated --example flags instead of assuming comma-separated parsing.
  • Inspect pina docs before requesting a topic.
  • Never use the input .so path as the profile output path.
  • Treat exit code 2 from verify check or verify record as a verified hash mismatch, not an operational failure.
  • Treat exit code 2 from rehearse as a completed rehearsal that found behaviour changes; read transactions[].status from the JSON report. Exit code 3 means no transaction could be compared, so nothing was verified; it is never a pass. Exit code 1 is an operational failure with empty stdout. Never pass --allow-changes until every state_changed and outcome_changed transaction has been reviewed.
  • pina rehearse sends each RPC request once and stops on the first failure. Do not loop it against a rate-limited public endpoint; use a dedicated endpoint or a smaller --limit.
  • Run pina build --verify first and pass its printed content-addressed JSON path to pina verify record --build-record.
  • Never infer a repository, revision, cluster, authority, or uploader. The build record binds source provenance; every network target and signing identity remains explicit.
  • Use --yes only for a reviewed record plan. Mainnet submissions additionally require --acknowledge-mainnet; transaction export requires neither flag.
  • Never put secrets in a custom RPC URL. Pina passes the RPC origin to solana-verify as argv.
  • Treat pina explain candidates by their confidence: only confirmed is proven by the transaction, checked_against_current_state reads state that may have changed after the transaction, and possible needs runtime values. Exit code 0 means an explanation was produced, including for a transaction that succeeded.
  • pina explain queries localnet unless --network or --rpc-url names another endpoint, and never retries a request.
  • Use pina test --unit when only native Rust or Mollusk tests are required.
  • Treat a missing tests/surfpool package or built .so as a failed integration setup, not a skip.
  • pina dev is offline unless --network or a credential-free HTTP(S) --rpc-url is explicitly supplied. The URL is visible in Surfpool’s process arguments, so never place a secret anywhere in it.
  • Always inspect deploy --dry-run --json before remote automation.
  • Never pass deploy --yes until the exact target, program ID, authority, payer, and command plan have been reviewed.
  • Prefer deploy --rehearse for upgrades of programs with traffic. Exit codes 2 and 3 mean the deployment stopped before anything was sent. Never pass --allow-rehearsal-changes until every state_changed and outcome_changed transaction has been reviewed, and do not treat 3 as a pass: deploy a first version without --rehearse instead.
  • Never put a secret anywhere in a custom deploy RPC URL. Pina rejects user information, queries, and fragments, but accepted hosts and paths remain visible in plan output and process listings because Solana receives the endpoint through --url. Prefer named clusters.

Stable verification

CLI help is snapshot-tested at every command level. IDL stdout is also regression-tested as valid JSON. The book is built by verify:docs and published by the repository’s GitHub Pages workflow, so command changes should update help tests and this reference in the same change.

Agent Skill

@pina-rs/skill packages task-focused guidance for agents that create, audit, or maintain Pina programs. It covers project setup, account and instruction authoring, PDA and authority validation, IDL and client generation, SBF profiling, tests, and compatibility review.

Install

npm install --global @pina-rs/skill
pina-skill --install

The installer copies the runtime skill to $CODEX_HOME/skills/pina when CODEX_HOME is set, otherwise ~/.codex/skills/pina. It refuses to replace an existing directory, so local skill changes cannot be lost silently.

Inspect the packaged source or manual installation details with:

pina-skill --print-path
pina-skill --print-install

Scope

The skill is intentionally specific to Pina. Its entrypoint establishes the program invariants that matter across tasks:

  • preserve no_std compatibility and the project’s feature boundary;
  • validate accounts before casts, mutation, resize, close, or CPI;
  • construct account-management and generated CPI operations as documented structs, then call invoke or invoke_signed;
  • keep discriminators and PDA seed namespaces explicit and stable;
  • treat generated IDLs and clients as reviewed public contracts;
  • verify changes at the smallest meaningful layer before running the full project suite.

Detailed guidance is split into focused references for setup, program authoring, migrations, CLI and code generation, and testing. Agents load only the reference needed for the current task.

Package layout

PathPurpose
SKILL.mdRuntime entrypoint and task routing
agents/openai.yamlDisplay metadata and invocation policy
references/project-setup.mdScaffolding, boundaries, features, and entrypoint
references/program-authoring.mdMacros, validation, PDAs, CPI, resize, and close
references/migrations.mdVersion envelopes, the migration loop, and publication
references/cli-and-codegen.mdCLI discovery, IDLs, clients, and profiling
references/testing.mdUnit, VM, SBF, generated, and release checks
bin/pina-skill.cjsNon-destructive installer and path discovery

Core Concepts

Discriminator layout (raw bytes)

Pina injects discriminator bytes as the first field of every #[account], #[instruction], and #[event] native schema. PinaPod then generates a separate zero-copy storage view with the same discriminator-first wire layout.

At runtime the parser checks the exact schema size and discriminator, delegates recursive content validation to PinaPod, and returns the generated TypeZc view under the runtime’s borrow guard.

offset | size | meaning
------ | ---- | -------
0      | N    | discriminator (N = BYTES of enum primitive: 1/2/4/8)
N      | ...  | payload fields

This contract is what enables:

  • deterministic Type::SIZE checks,
  • zero-copy validation with as_account() / try_from_bytes(),
  • alignment-one storage fields generated by PinaPod.

Why this is safer than implicit external headers

External fixed-size headers require separate offset logic in each parse path. With an auto-injected first field, the PinaPod derive owns one schema and one validated storage layout. Pina does not manually cast the native schema or expose its object representation.

Discriminator width and compatibility

The enum primitive width controls both on-chain layout and migration surface.

  • Width is set on the discriminator enum using #[discriminator(primitive = u8)] (default u8).
  • Allowed widths are u8, u16, u32, and u64.
  • The maximum practical width is capped at 8 bytes for zero-copy safety.

Discriminators and ABI migrations

ChangeCompatibility impact
Add a new discriminator variantBackward-compatible; existing routes keep their identity
Change an existing discriminator valueBreaking for every historical byte slice
Change a migration-aware account or enveloped instruction payloadCompatible only when the checked-in history has an adjacent transition
Change a published versioned event schemaCompatible; a new version is appended and clients decode each version with its own schema
Change a published snapshot-only instruction payloadBreaking; create a new instruction discriminator
Append optional accounts to an instruction routeCompatible when the existing positional list remains an identical prefix
Reorder, remove, or escalate an instruction slotBreaking; create a new instruction discriminator
Change the migration version width after releaseBreaking for every enveloped wire contract

Add migrations to an account, instruction, or event attribute to opt one contract into a framework-owned version field, or opt whole contract kinds in when you record the history:

pina migrations create --auto true # or --auto accounts,events,instructions

--auto accepts true, false, or a comma-separated list of accounts, events, and instructions. pina migrations create records the policy in migrations/manifest.json, its only home, and snapshots every contract of the listed kinds; a later run without the flag keeps the recorded policy. pina.toml holds no migration policy. Macros read the policy from the manifest, so a new struct still fails the build with “run pina migrations create” until it has a snapshot. Once a policy is recorded, create also scaffolds a build.rs emitting cargo:rerun-if-changed=migrations/manifest.json, so flipping the policy re-expands every contract without editing source. Add migrations = false to keep one contract out of an auto policy; removing an envelope the manifest already records is an error instead of a silent opt-out, because stripping an envelope is itself a wire-format change.

The policy gives accounts and events the version field. It records each instruction as a snapshot without one: the payload keeps its [discriminator][payload] wire format, and the build fails when the struct drifts from the snapshot. Add migrations to an #[instruction] to give that instruction the version field and adjacent transitions instead; the #[discriminator(entrypoint)] dispatcher then converts an older payload to the current layout before the handler runs.

Pina places the version field immediately after the discriminator. The accepted encodings are u8, u16, and u32; u8 is the default and the recommended choice. Versions are tracked per contract, not per program: each account, instruction, and event owns an independent history that starts at version 0, so u8 gives every contract its own 255-version budget. Rewriting one contract 255 times is not a realistic outcome, and the narrower field costs one byte in every enveloped account. Choose a wider encoding with pina migrations create --version-type u16 (or u32) before the first release only when you expect a single contract to exceed 255 versions. The width is program-wide, recorded as versionType in the manifest, and freezes at the first published release: after that the flag fails instead of widening it. Any other value, including u64, is rejected with an error naming the supported widths. Discriminator width is a separate setting, and that one does support u64.

Run pina migrations create before a release. Pina updates the replaceable draft when the current version is unpublished. After pina deploy records a non-local publication, the next schema change creates a new version, with an adjacent transition for an account or an enveloped instruction; the payload of a published snapshot-only instruction cannot change. Normal builds run pina migrations check and fail on drift, incomplete manual transitions, or changed published code.

An old instruction can omit only newly appended optional accounts. Pina does not synthesize signers, writable privileges, PDAs, or required accounts. Any change to an existing process slot requires a new discriminator.

Historical events are immutable, so Pina versions events instead of migrating them. The program emits only the current version. Generated clients decode each earlier version with its own schema, as a separate <Event>V<n> event, and a log record whose version no generated event describes fails instead of being misread.

Discriminator layout decision matrix

The discriminator strategy determines byte layout, parser guarantees, and cross-protocol compatibility.

GoalRecommended layout
Keep layout minimal and zero-copy while staying explicitCurrent 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 typesUse u8 (default) discriminator width.
You need more than 256 route variantsUse u16 / u32 / u64 by setting #[discriminator(primitive = ...)].
Avoid schema migrations across existing serialized dataKeep existing field order and discriminator values; only append fields.

Raw discriminator width by use-case

WidthMax variantsStorage cost (bytes)Recommended when
u82561Most programs and instructions
u1665,5362Medium-large routing tables and explicit version partitioning
u324,294,967,2964Very large enums, rarely needed
u6418,446,744,073,709,551,6168Legacy interoperability shims or reserved growth
  • Discriminator width only affects the first field bytes.
  • Widths above 8 are rejected at macro expansion time.
  • Wider discriminators improve variant space, but increase CPI payload and account rent by the exact number of bytes.
  • These widths describe discriminators only. The migration version envelope is a separate setting and accepts u8, u16, or u32 — never u64.

Zero-copy account models

#[account] and #[instruction] keep the declared Rust type as a native schema and derive a separate PinaPod TypeZc storage view. Checked loaders validate the complete byte slice before returning that in-place view. The native schema itself is never reinterpreted as bytes.

Closed macro-generated schema grammar

Pina’s audited zero-copy boundary is the code generated by #[account], #[instruction], and #[event]. Those macros accept only representations whose alignment, size, initialization, and bit validity Pina can prove:

  • native integer scalars and bool,
  • Pina’s PodU*, PodI*, and PodBool wrappers,
  • Pina’s exact Address type,
  • [u8; N] with a literal length,
  • [T; N] typed arrays where T is any other fixed grammar type and N is a literal length; storage is [PodT; N] little-endian with no length prefix, and validation recurses per element. Nested arrays such as [[u8; 4]; 2] compose,
  • String<N> and PodString<N, PFX> with literal capacities and prefix widths,
  • Vec<T, N> and PodVec<T, N, PFX> where T has a fixed audited representation,
  • Option<T> where T has a fixed audited representation,
  • with the floats feature: f32 and f64, stored as the bit pattern of their backing little-endian integer, and fixed-point FixedI*<Frac>/FixedU*<Frac> types from the pinned fixed crate. See Float and Fixed-Point Fields.

The fixed grammar is recursive through arrays, String, Vec, and Option. PinaPod fully initializes collection capacity and validates each active nested element. The macros still reject generics, arbitrary custom ZcField mappings, char, NonZero*, and representations whose alignment or bit validity Pina cannot prove.

Compact accounts use a narrower top-level grammar because tails need generated offset and patch logic. See Compact Accounts for the accepted forms.

Direct PinaPod derives and manual PinaAccount or PinaPodFixed implementations remain available for advanced integration, but they are outside Pina’s audited macro-generated contract. Implementing PinaPodFixed is unsafe; authors must uphold PinaPod’s bit-validity, alignment, initialization, size, validation-order, and aliasing invariants.

The optional crate = ... macro argument is path configuration for renamed dependencies. It must resolve to Pina itself, or to a transparent re-export of the same crate; it is not an extension point for substituting another derive or trait implementation universe.

Account validation chains

Validation methods on AccountView are composable and preserve the receiver type:

#![allow(unused)]
fn main() {
account.assert_signer()?.assert_writable()?.assert_owner(&program_id)?;
}

A chain that starts with &AccountView stays shared, while a chain that starts with &mut AccountView stays mutable. This keeps writability explicit without losing access to as_account_mut() later.

Use assert_program() when you explicitly validate a program account. Static .invoke() and .invoke_signed() builders encode their program ID. Pinocchio Token’s .invoke_with_program() and .invoke_signed_with_program() methods validate their supplied ID with Program::verify(). Neither form needs a preceding account assertion.

If you call .invoke_with_unverified_program() or .invoke_signed_with_unverified_program(), validate the exact supplied account first against a const or immutable static whose type contains no interior mutability, or an unmodified local alias of one. An expected ID supplied through instruction data is attacker-controlled and does not authenticate the target. A const or static whose type contains UnsafeCell — including a const of reference type aliasing an interior-mutable static — can be rewritten at runtime and is rejected as provenance for the same reason. Prefer Pina’s assert_program(), propagate assertion failure, and call the method directly. The lint requires validation on every continuing path. You can bind or chain from the account value returned by the assertion. Success-side Result callbacks, assignments, mutable borrows, &mut self method calls, and closures that may replace the validated binding invalidate the proof. The lint also rejects storing an unverified CPI method as a function value because doing so hides the target argument from its local proof.

When you need sysvar data, prefer Pinocchio’s checked typed loaders:

#![allow(unused)]
fn main() {
let clock = Clock::from_account_view(clock_account)?;
let rent = Rent::from_account_view(rent_account)?;
let instructions = Instructions::try_from(instructions_account)?;
}

These loaders validate the sysvar address while parsing. Their results can flow through normal Rust extraction, adapters, tuples, patterns, and control flow without extra assertions. Pina instead rejects constructors that do not validate identity. These include the Clock and Rent byte constructors, Instructions::new_unchecked, and SlotHashes::new or new_unchecked. Call these constructors directly. Storing one as a function value is also rejected so the unvalidated source remains visible.

Keep assert_sysvar() for identity-only checks and deliberate raw data access. A raw-access proof must call Pina’s method with the matching pina_sdk_ids::sysvar::<name>::ID. For a generic binding such as epoch_sysvar, the recognized canonical ID supplies the otherwise missing identity. Enforce its Result on every continuing path. You can bind or chain from the account value returned by the assertion. Success-side Result callbacks, assignments, mutable borrows, &mut self method calls, and closures that may replace the asserted binding invalidate the proof. Reviewed manual parsing also needs a narrow lint allowance on its constructor.

Typed account conversions

Traits in crates/pina/src/impls.rs provide typed conversion paths from raw AccountView values into strongly typed account states. as_account() returns Ref<T> and as_account_mut() returns RefMut<T> borrow guards. The type aliases LoadedAccount<'a, T> and LoadedAccountMut<'a, T> are provided for Ref<'a, T> and RefMut<'a, T> respectively, offering a more descriptive name for guard-backed typed account access.

A fixed account with a stored #[pda(bump = ...)] generates Type::load_pda and Type::load_pda_mut. These methods combine typed account validation with stored-bump address validation and return the same guard types. Prefer them over an assert_type → Type::assert_seeds → as_account* sequence when the handler immediately needs the state; the sequence recursively validates bounded fields more than once.

Use this order for fixed accounts:

  1. Use generated load_pda or load_pda_mut for a stored-bump PDA when the handler needs typed fields.
  2. Use as_account or as_account_mut for another fixed account when the handler needs typed fields.
  3. Use assert_type only when the handler needs to validate an existing fixed account without loading its fields.

All three paths validate the owner, discriminator, exact account size, and active nested values. assert_type releases its data borrow before returning. It is therefore a moment-in-time validation, not a guard for a later raw cast or mutation. Do not call it before either typed-loader path.

A compact account with a stored bump generates two closural loaders. Type::with_stored_bump_pda validates ownership, the compact representation, and the address the stored bump derives before it runs the closure, using a single derivation. Type::with_checked_pda searches for the canonical bump instead and additionally rejects a stored bump that is not canonical, which is what catches a shadow account created at a noncanonical bump; prefer it when an untrusted caller chooses which account the handler loads. The runtime data borrow remains active for the closure in both, so the compact view cannot outlive its validated bytes. Keep assert_compact_type and generated assert_seeds for validation-only paths that do not need the compact view.

Account cursors

AccountsCursor is the runtime layer used by #[derive(Accounts)]. It advances through the account slice from left to right and rejects writable aliases for mutable accounts parsed individually through next_mut(), without heap allocation. Alias checks look forward: a mutable field must not reappear in any later slot. A readonly field followed by a mutable field for the same account is accepted, because an authority that signs readonly and also pays is one account in two slots, and the runtime marks both slots writable. When two fields must be distinct accounts, compare their addresses explicitly instead of relying on declaration order. Accounts are compared by AccountView identity: the entrypoint deserializer makes every duplicate slot a copy of the original view, so each check is a single pointer comparison. Explicit trailing-account capture via #[pina(remaining)] preserves account order and rejects duplicate mutable addresses by default; mutable trailing slices also reject readonly accounts. When duplicate addresses are an intentional part of the instruction contract, #[pina(remaining, distinct = false)] restores pass-through aliasing and the field must have a doc comment explaining the invariant that makes it safe. A missing trailing Option<&AccountView> or Option<&mut AccountView> parses as None, which lets a current process accept the shorter positional prefix sent by an older client. An optional field before another positional field still occupies a slot.

Optional accounts

Account fields wrapped in Option mark a slot as optional. Trailing optional fields may be omitted entirely; optional fields before another positional field keep their slots:

#![allow(unused)]
fn main() {
#[derive(Accounts)]
pub struct MakeAccounts<'a> {
	pub maker: &'a mut AccountView,
	pub escrow: Option<&'a mut AccountView>,
	pub witness: Option<&'a AccountView>,
}
}

Only Option<&'a AccountView> and Option<&'a mut AccountView> are supported; other inner types fail to compile.

Within a positional list, the absent convention is the executing program’s own address. Generated Codama clients may fill an omitted optional slot with a readonly account meta pointing at the program address, and on-chain parsing maps it back to None. A trailing optional suffix may instead be left out, including by an older client that predates those fields. Because the filler is readonly, provided values still enforce their declared writability through next_mut_opt().

Program logic branches on presence with plain pattern matching. Load the account directly when the branch needs its fields:

#![allow(unused)]
fn main() {
if let Some(escrow) = self.escrow {
	let escrow = escrow.as_account::<EscrowState>(&ID)?;
	// Read validated fields through `escrow`.
}
}

Optional signers are validated only when present (if let Some(witness) = self.witness { witness.assert_signer()?; }). In generated clients an optional signer input is a TransactionSigner, so providing one attaches the signature automatically.

Instruction authoring tips

  • Entry points should accept &mut [AccountView] and dispatch with Accounts::try_from((program_id, accounts))?.process(data).
  • Use &AccountView for read-only accounts and &mut AccountView only when you need mutable loaders, direct lamport mutation, close_* helpers, or writable IDL inference.
  • &mut AccountView declares and enforces a writable slot. Use assert_writable() or #[pina(validate(writable))] only when a shared &AccountView must arrive writable.
  • as_account() / as_account_mut() return Ref<T> / RefMut<T> borrow guards. Copy out the fields you need and drop(...) the guard before CPIs or later mutable borrows.
  • Prefer generated load_pda* methods for stored-bump fixed PDAs, then as_account* for other fixed accounts. Use assert_type only when no typed fields are needed; do not call it before a typed loader.
  • Keep validation chains direct inside process(self, ...) when possible. That makes audits easier and gives pina idl the clearest signal for signer, writable, PDA, and default-account inference.

Entrypoint model

nostd_entrypoint! wires BPF entrypoint plumbing while preserving no_std constraints for on-chain builds.

A program routed by #[discriminator(entrypoint)] can use dispatch_entrypoint!(Instruction) instead. It reads the instruction before any account, then walks only the accounts the routed struct reads, which makes most programs smaller and cheaper to run. See Program size for when each entrypoint measures smaller.

Pod types

TypeWrapsSize
PodBoolbool1 byte
PodU16u162 bytes
PodI16i162 bytes
PodU32u324 bytes
PodI32i324 bytes
PodU64u648 bytes
PodI64i648 bytes
PodU128u12816 bytes
PodI128i12816 bytes

All types are alignment-one byte-backed values that implement PinaPod’s ZcElem and ZcValidate contracts. The floats feature adds PodF32 and PodF64, which store IEEE-754 bit patterns in four and eight bytes; they validate any bit pattern and decode an all-zero field as +0.0.

Arithmetic operators (+, -, *) on Pod integer types use wrapping semantics in release builds for CU efficiency and panic on overflow in debug builds. Use checked_add, checked_sub, checked_mul, checked_div where overflow must be detected in all build profiles.

Each Pod integer type provides ZERO, MIN, and MAX constants.

This means you can write ergonomic code like:

#![allow(unused)]
fn main() {
my_account.count += 1u64;
let fee = balance.checked_mul(3u64).unwrap_or(PodU64::MAX);
}

Instruction introspection

The pina::introspection module provides helpers for reading the Instructions sysvar at runtime. This enables:

  • Program checks: verify that the transaction-level instruction at the current index targets the expected program (assert_current_instruction_program_id). The Instructions sysvar cannot distinguish self-CPI, so this is not a no-CPI or flash-loan guard.
  • Transaction inspection: count instructions (get_instruction_count) or find the current index (get_current_instruction_index)
  • Sandwich detection: check whether a specific program appears before or after the current instruction (has_instruction_before, has_instruction_after)

Float and Fixed-Point Fields

Pina schemas can store fractional numbers two ways, each behind its own optional feature:

  • floats enables native f32 and f64 fields, converted to and from their bit pattern under the hood through the PodF32/PodF64 pods that pinapod provides, and
  • fixed enables fixed-point FixedI*<Frac> and FixedU*<Frac> types from the pinned fixed crate, re-exported as pina::fixed.

Both forms are stored as the complete bit pattern of a backing little-endian integer pod. Every bit pattern is a valid stored value, so zero-copy reads stay total and validation stays O(1) per field.

The features are independent: enabling floats alone does not pull in the fixed crate, and enabling fixed alone does not provide f32/f64 fields. Enable what the program actually stores:

[dependencies]
pina = { version = "...", features = ["floats"] } # f32/f64 fields
pina = { version = "...", features = ["fixed"] } # FixedI*/FixedU* fields
pina = { version = "...", features = ["floats", "fixed"] } # both

Either feature also enables derive, so no separate dependency entries are needed.

Native float fields

With floats enabled, declare f32 and f64 exactly like any other scalar. The generated zero-copy view converts through pinapod’s PodF32 and PodF64 storage pods, so accessors take and return native floats — the same ergonomics as u32 fields converting through PodU32:

#![allow(unused)]
fn main() {
use pina::*;

#[account(discriminator = ReadingKind::Live)]
pub struct LiveReading {
	pub temperature: f32,
	pub depth: f64,
	pub bias: Option<f32>,
	pub samples: Vec<f32, 4>,
}

#[instruction(discriminator = ReadingKind::Calibrate)]
pub struct Calibrate {
	pub offset: f64,
}
}

Storage accessors convert automatically:

  • state.temperature.set(-12.5) stores (-12.5f32).to_bits() little-endian.
  • state.temperature.get() returns the decoded f32.
  • Option<f32> and Vec<f32, N> compose with the standard bounded-collection grammar.
  • Compact accounts accept f32/f64 as inline header fields, and compact patches take native floats. Both spellings work for optional fields: patch.maybe_bias(Some(0.5)) and patch.maybe_bias(None) need no pod import, because pinapod implements the field mapping for the f32 primitive itself.

Fixed-point fields

Fixed-point types represent fractional values as scaled integers: a FixedU64<U16> stores value × 2¹⁶ in a u64. Declare them with a typenum fractional-bits parameter, which Pina re-exports through pina::fixed:

#![allow(unused)]
fn main() {
use pina::fixed::FixedU64;
use pina::fixed::types::extra::U16;
use pina::*;

#[account(discriminator = PriceKind::Live)]
pub struct LivePrice {
	pub price: FixedU64<U16>,
	pub authority: Address,
}
}

Fixed-point fields expose their backing bits through the standard pod accessors. Convert at the boundary with from_bits/to_bits, or construct exact values with from_num:

#![allow(unused)]
fn main() {
let price = FixedU64::<U16>::from_num(3);          // exactly 3.0
state.price.set(price.to_bits());
let decoded = FixedU64::<U16>::from_bits(state.price.get());
assert_eq!(decoded.to_bits(), 3 << 16);
}

Valid Frac widths depend on the backing width; a FixedU64<Frac> supports 0–64 fractional bits. The fixed crate rejects invalid parameters at compile time and provides checked_*, saturating_*, and wrapping_* arithmetic alongside the default operators.

Wire format and generated clients

Both families are stored as the complete bit pattern of a backing little-endian integer:

Schema typeStorageWire bytes
f32PodF32f32::to_bits() as u32 LE
f64PodF64f64::to_bits() as u64 LE
FixedI8<Frac>i81 byte
FixedI16<Frac>PodI162 bytes
FixedI32<Frac>PodI324 bytes
FixedI64<Frac>PodI648 bytes
FixedI128<Frac>PodI12816 bytes
FixedU8<Frac>u81 byte
FixedU16<Frac>PodU162 bytes
FixedU32<Frac>PodU324 bytes
FixedU64<Frac>PodU648 bytes
FixedU128<Frac>PodU12816 bytes

Generated Codama clients describe these fields as their backing little-endian integers (u32, u64, i64, and so on). The IDL describes the wire, and the wire is an integer bit pattern; a client that wants a float divides by 2^Frac (fixed-point) or converts the bits itself (IEEE-754). This keeps every existing Rust, TypeScript, and Dart client working without float codec support.

Choosing between fixed-point and floating point

Prefer fixed-point for anything that touches value, accounting, or consensus-sensitive math:

ConcernFixed-point (Fixed*)Native float (f32/f64)
DeterminismExact integer arithmetic; every validator agreesIEEE-754 semantics with rounding at every operation
Representable valuesExactly value × 2^Frac within rangeDense near zero, gappy at magnitude; no exact decimals
Overflow behaviorSame as Rust integers: panic in debug, wrap in release; checked_* availableDrifts toward infinity; silently loses precision
Solana compute unitsPlain integer instructionsSBF has no hardware float unit: every operation lowers to soft-float sequences
Client ergonomicsDivide by 2^Frac (an exact shift)Reconstruct IEEE bits by hand in every client

Use native floats when the values are inherently approximate sensor-style readings, when the math must match an off-chain float model bit-for-bit, or when interoperating with a program that already stores raw float bits (for example, Anchor’s floats pattern, which Pina’s float_accounts_program example ports). In that case store the bits and treat them as opaque: comparisons and aggregation belong off-chain, where the float model is defined.

Solana execution is deterministic across validators, but IEEE-754 does not make cross-platform float math trivially reproducible (FMA contraction, libm differences in transcendentals, and NaN payload propagation all vary). Fixed-point avoids the entire class: integer addition is integer addition everywhere.

Build impact

The two features have very different costs, which is part of why they are separate:

  • floats adds no crates. PodF32/PodF64 are four- and eight-byte wrappers in pinapod, which Pina already depends on. A program that enables the feature without using it links nothing extra, and the pina rlib stays byte-identical (609,304 bytes when measured).
  • fixed adds two no_std crates to the dependency graph — fixed =1.30.0 and its only dependency typenum. Compiling them costs about 9.5 seconds once, and about 0.4 seconds on a cached rebuild. A program that only stores f32/f64 fields can leave this out entirely.
  • The fixed crate is heavily generic: its own rlib is large (about 28 MB with debug metadata), but only the fixed-point types a program actually instantiates are monomorphized into the final SBF binary. A program that uses one FixedU64<U16> field pays for exactly that instantiation.
  • The exact =1.30.0 pin is deliberate: Pina’s generated code and pinapod’s ZcField implementations expand against fixed’s type-level contracts, so mixed versions cannot be allowed to coexist. Pina re-exports the pinned instance as pina::fixed; derive your schema fields from that path and the version question disappears.

Security notes

  • Every bit pattern is a valid value. Unlike char or NonZero*, no float or fixed-point bit pattern is an invalid state, so zero-copy casts remain sound and validation cannot reject a legitimate value.
  • Zeroed storage is value zero, not an invalid account. The typed discriminator still guards account identity: a fully zeroed account fails validation until write_discriminator runs. After initialization, an unset Option<f32> reads as None and a zeroed fixed-point reads as 0.0.
  • NaN is a value, not an error. Float storage preserves bit patterns exactly, including NaN payloads, and PodF32/PodF64 compare bitwise so Eq stays sound. On-chain float arithmetic is still the author’s responsibility: NaN == NaN is false in native float math, and validators cannot detect a program that treats NaN inconsistently.
  • Use checked arithmetic for value-bearing state. Fixed-point operators panic on overflow in debug builds and wrap in release, matching Rust integer semantics. Prefer checked_add/checked_sub (or the pod’s checked_* helpers on the backing integer pods) in any path that moves value.
  • Float precision is a protocol decision. Two floats written by the same instruction read back identically; only arithmetic introduces rounding. Keep multi-step float computation off-chain or in fixed-point, and use on-chain floats as stored configuration or recorded observations.
  • Clients see integers. A TypeScript or Dart client that naively renders a fixed-point field shows the raw scaled integer. Scale on the client with an exact divisor derived from the documented Frac parameter.

Declarative Validation

Pina’s opt-in validation feature adds allocation-free application validation to #[account], #[instruction], #[event], and #[derive(Accounts)]. Add it to the program dependency:

[dependencies]
pina = { version = "0.15", features = ["validation"] }

Each annotated macro generates a PinaValidate implementation with fn validate(&self) -> ProgramResult. Validation fails fast with the first Solana ProgramError; it does not allocate, collect an error tree, deserialize into a second value, or use dynamic dispatch.

Pina runs generated validation automatically after structural decoding in try_from_bytes, after fixed or compact initialization, after compact updates, and after #[derive(Accounts)] parses the received account slice. Failed initialization leaves the destination zeroed. Call .validate() directly when validating an already-borrowed value.

Mutating a fixed view can invalidate a previously checked rule, so validate again before emitting an event or committing application state when the mutation itself must be checked. Compact updates return an error when the completed representation violates an application rule. Always propagate that error with ?; Solana transaction rollback is what restores the pre-update bytes and any earlier rent movement.

Value Rules

Use #[pina(validate(...))] on fields of #[account], #[instruction], and #[event] structs. Each rule is a comparison over the field’s value or its len, so the annotation reads as the check it generates:

RuleAccepted fieldsMeaning
value == EXPRFixed-width integers and Pina Pod* integer fieldsNumeric equality
value != EXPRFixed-width integers and Pina Pod* integer fieldsNumeric inequality (for example, non-zero)
value < EXPRFixed-width integers and Pina Pod* integer fieldsExclusive numeric upper bound
value <= EXPRFixed-width integers and Pina Pod* integer fieldsInclusive numeric upper bound
value > EXPRFixed-width integers and Pina Pod* integer fieldsExclusive numeric lower bound
value >= EXPRFixed-width integers and Pina Pod* integer fieldsInclusive numeric lower bound
len == EXPRString, PodString, Vec, PodVec, and arraysExact byte or element count
len != EXPRString, PodString, Vec, PodVec, and arraysAny other byte or element count
len < EXPRString, PodString, Vec, PodVec, and arraysExclusive maximum byte or element count
len <= EXPRString, PodString, Vec, PodVec, and arraysInclusive maximum byte or element count
len > EXPRString, PodString, Vec, PodVec, and arraysExclusive minimum byte or element count
len >= EXPRString, PodString, Vec, PodVec, and arraysInclusive minimum byte or element count
error = ERROROne validation groupReplaces the macro’s default ProgramError

String lengths are UTF-8 byte lengths. Vector and array lengths are element counts. Chain bounds on the same receiver with && (value >= 1 && value <= 10, len == 4) and separate rules with ,. A range can also be written as one rule — 100 < value <= u64::MAX — which generates the same two checks joined by &&. A rule that must fail in different ways takes error = ERROR in the same group.

The min, max, min_len, max_len, and exact_len parameter spellings predate comparisons. They still parse and generate the identical checks, but they are deprecated and warn at the parameter.

Use validate(with = function) in the outer macro for cross-field or domain validation. The hook is a named parameter, not a comparison: it keeps =. Fixed schemas pass their generated *Zc view; compact accounts pass their generated *Ref<'_> view. The function must return ProgramResult.

#![allow(unused)]
fn main() {
#[instruction(
	discriminator = Instruction::Transfer,
	validate(with = validate_transfer)
)]
pub struct TransferInstruction {
	#[pina(validate(value >= 1 && value <= 1_000_000, error = TransferError::InvalidAmount))]
	pub amount: u64,

	#[pina(validate(len <= 64))]
	pub memo: String<64>,
}

fn validate_transfer(value: &TransferInstructionZc) -> ProgramResult {
	if value.amount() == value.memo().len() as u64 {
		return Err(TransferError::AmbiguousTransfer.into());
	}

	Ok(())
}
}

For accounts, the default error is ProgramError::InvalidAccountData. Instructions and events default to ProgramError::InvalidInstructionData. Put error = ... in a validation group when callers need a domain-specific error.

Read Migrate value rules to comparisons for the rewrite table from the deprecated parameter spellings.

Instruction Account Rules

Fields in #[derive(Accounts)] accept these rules:

RuleGenerated check
signerRequires the transaction signer flag
writableRequires the writable flag on a shared &AccountView field
executableRequires an executable account
address = EXPRRequires one exact address
addresses = EXPRAccepts any address in a slice or array
owner = EXPRRequires one exact owner
owners = EXPRAccepts any owner in a slice or array
program = EXPRRequires both the program address and executable flag
sysvar = EXPRRequires both the canonical sysvar address and sysvar owner
emptyRequires empty account data
not_emptyRequires non-empty account data
data_len = EXPRRequires an exact account-data length
distinct_from = FIELDRequires two present account fields to have different addresses
error = ERRORReplaces the standard error for every check in that validation group

Use &mut AccountView or Option<&mut AccountView> to declare a writable slot. Parsing already enforces writability for those types, so adding writable is a compile-time error with a suggested fix. Use the annotation only when a shared reference must still arrive writable.

#![allow(unused)]
fn main() {
#[derive(Accounts)]
#[pina(validate(with = validate_transfer_accounts))]
pub struct TransferAccounts<'a> {
	#[pina(validate(signer))]
	pub authority: &'a AccountView,

	#[pina(validate(owner = ID, not_empty))]
	pub source: &'a mut AccountView,

	#[pina(validate(owner = ID, not_empty, distinct_from = source))]
	pub destination: &'a mut AccountView,

	#[pina(validate(program = token::ID))]
	pub token_program: &'a AccountView,
}

fn validate_transfer_accounts(accounts: &TransferAccounts<'_>) -> ProgramResult {
	if accounts.authority.address() == accounts.destination.address() {
		return Err(TransferError::InvalidAuthority.into());
	}

	Ok(())
}
}

Generated account validation has a stable order: account-slice parsing and implicit writable/duplicate checks; signer, writable, and executable checks; address and owner checks; data checks; cross-field relationships; nested Accounts validation; then the struct-level hook. This puts cheap header checks before account-data borrows and gives custom hooks a fully validated input.

Constraints that perform lifecycle work—account creation, PDA discovery, realloc, and close—remain explicit builders or validation calls. They are not hidden in .validate().

Manual Validation and Codama

The annotations are syntax sugar, not a separate validation engine. Every account rule delegates to the existing AccountInfoValidation method with the same name or meaning. You can keep direct validation chains without enabling validation or using the new annotations:

#![allow(unused)]
fn main() {
self.authority.assert_signer()?;
self.state
	.assert_owner(&ID)?
	.assert_not_empty()?
	.assert_writable()?;
self.system_program.assert_program(&system::ID)?;
}

You can also write an ordinary function returning ProgramResult, call it at the boundary, or manually implement PinaValidate when the validation feature is enabled. Prefer the form that keeps the security contract easiest to audit.

Codama generation supports both styles. pina idl reads declarative signer and writable rules plus known address, program, and sysvar constants from #[derive(Accounts)]. Direct assert_signer, assert_writable, assert_address, and PDA validation-chain inference remains supported, including validation inside module-level helper functions the processor passes an account to, and typed loads of #[pda] account types such as as_account::<T>(). Runtime-only value bounds, owners, data lengths, relationships, and custom hooks do not have Codama account-meta equivalents; they stay on-chain constraints and do not prevent IDL or client generation.

Before and After

Before the validation feature, programs wrote boundary checks directly in each processor. This remains supported:

#![allow(unused)]
fn main() {
let args = TransferInstruction::try_from_bytes(data)?;
if args.amount() == 0 || args.amount() > 1_000_000 {
	return Err(TransferError::InvalidAmount.into());
}

self.authority.assert_signer()?;
self.source.assert_owner(&ID)?.assert_not_empty()?;
self.token_program.assert_program(&token::ID)?;
}

With the feature enabled, the same reusable checks can live beside the fields that declare the boundary. try_from_bytes and #[derive(Accounts)] run them automatically before process receives the decoded values:

#![allow(unused)]
fn main() {
#[instruction(discriminator = Instruction::Transfer)]
pub struct TransferInstruction {
	#[pina(validate(
		value >= 1 && value <= 1_000_000,
		error = TransferError::InvalidAmount
	))]
	pub amount: u64,
}

#[derive(Accounts)]
pub struct TransferAccounts<'a> {
	#[pina(validate(signer))]
	pub authority: &'a AccountView,

	#[pina(validate(owner = ID, not_empty))]
	pub source: &'a mut AccountView,

	#[pina(validate(program = token::ID))]
	pub token_program: &'a AccountView,
}
}

The generated code calls the same validation primitives as the manual form. This makes the annotations removable syntax sugar rather than a second security model.

Complete Boundary-Validation Example

The examples/validation_program project uses the feature across every supported macro boundary:

BoundaryExample coverage
Instruction dataNumeric bounds, bounded strings, exact vector lengths, custom errors, and a cross-field hook
Instruction accountsSigner, writable, owner, program, empty, non-empty, distinct-account rules, and struct hooks
Stored account stateNumeric bounds and a hook that keeps the minimum no greater than the maximum
EventsNumeric, string, and vector constraints plus a hook that rejects duplicate approvals

The processor also keeps one policy rule explicit because it combines decoded instruction data with loaded account state. That distinction is intentional: annotations validate one received value or account list, while ordinary Rust remains the clearest place for rules spanning multiple boundaries.

Run its native and deployed-program tests from the repository root:

devenv shell -- cargo test -p validation_program
devenv shell -- pina test --project examples/validation_program

The existing events_program also enables validation and applies event rules without changing its transport-focused structure. It is the smaller reference for adding validation to an established program.

How a migration flows

This page is the map of the whole migration system: what happens when a developer changes a contract, what the program does at runtime, and exactly what is expected from clients. Everything described here is the implemented behavior — ADR 0008 collects the parts that are designed but not built yet.

An interactive version of this page is at flow-interactive.html.

The one-paragraph answer

Migration happens on-chain, inside the program, on demand. A client never migrates account data and never needs to know a migration exists. An account carries a version envelope right after its discriminator, and the program migrates a stale account to its current representation inside the transaction that touches it. An instruction payload is recorded as a snapshot that fails the build on a wire-breaking change; only an instruction that opts in with migrations carries the envelope, and the generated dispatcher normalizes an older payload before the handler runs. An event is versioned, not migrated: the program emits only the current version, and generated clients decode each historical version with the schema that emitted it. Wherever an envelope exists, every client writes the version it was generated with. If the transaction fails, Solana rolls the migration back with everything else.

What each contract kind carries

KindWire formatRecorded historyAfter publication, a schema change…
Account[discriminator][version][payload]every version, joined by adjacent transitionsappends a version; stale accounts migrate on-chain when touched
Instruction under auto only[discriminator][payload]one snapshot, recorded as "envelope": falseis rejected; only appending optional accounts is allowed, in place
Instruction with the migrations token[discriminator][version][payload]every version, joined by adjacent transitionsappends a version; the dispatcher normalizes older payloads before the handler runs
Event with migrations or under auto[discriminator][version][payload]every version, each with its own schema and no transitionsappends a version; the program emits only the newest, and clients decode every version

The manifest key kind:width:hex (for example instruction:1:00) is the contract’s identity, and version numbers are positions in its versions array. ABI document versioning covers the document itself.

Development-time flow

Opting in

One contract opts in with the migrations token; a whole program opts in with the --auto flag of pina migrations create:

pina migrations create --auto true # or --auto accounts,events,instructions, or a staged subset

create records the policy as auto in migrations/manifest.json and snapshots every contract of the listed kinds; a later run without the flag keeps it. The manifest is the only home of the policy and of the version width, so it stays the checked-in source of truth that macros consult. pina.toml holds neither: its retired [migrations].auto and [migrations].version_type keys fail every command that reads it, naming the flag that replaces them. A declaration the manifest does not record yet still fails the build with the pina migrations create remedy. Because a proc macro does not re-expand when the manifest changes, a program with a policy also gets a build script emitting cargo:rerun-if-changed=migrations/manifest.json; create scaffolds it or reports the exact line when a hand-written build script must be edited. Explicit migrations = false overrides the policy for one contract, and removing an envelope the manifest already records fails closed as a wire-format change, whether it comes from the token, from --auto, or from a hand edit of the manifest’s auto.

The policy envelopes accounts and events. It records an instruction without an envelope unless the declaration opts in with #[instruction(discriminator = X, migrations)].

                 ┌──────────────────────────────┐
                 │ developer changes a struct,  │
                 │ instruction, or event        │
                 └──────────────┬───────────────┘
                                │
                                ▼
                 ┌──────────────────────────────┐
                 │ `pina build` / macro drift   │
                 │ gate fails:                  │
                 │ "run pina migrations create"   │
                 └──────────────┬───────────────┘
                                │
                                ▼
                 ┌──────────────────────────────┐
        ┌────────│   `pina migrations create`     │─────────┐
        │        └──────────────┬───────────────┘         │
        │                       │                         │
        │ ambiguous change?     │ plain change            │ unpaired removal
        ▼                       ▼                         ▼
┌───────────────┐   ┌────────────────────────┐   ┌──────────────────┐
│ terminal:     │   │ automatic transition   │   │ fails with the   │
│ prompt per    │   │ generated (byte moves, │   │ exact flag:      │
│ field         │   │ zero-fill, relayout)   │   │ --assume-removed │
│               │   └───────────┬────────────┘   └────────┬─────────┘
│ no terminal:  │               │ type change / compact?  │
│ fails naming  │               ▼                         │
│ --rename a:b  │   ┌────────────────────────┐            │
│ or            │   │ manual TODO transition │            │
│ --assume-     │   │ developer fills body,  │            │
│  removed a    │   │ re-runs make to record │            │
└───────┬───────┘   │ its hash               │            │
        │           └───────────┬────────────┘            │
        └─────────────┬─────────┴─────────────────────────┘
                      ▼
        ┌──────────────────────────────┐
        │ `pina migrations check`      │
        │ drift, hashes, publication   │
        │ pins all verified            │
        └──────────────┬───────────────┘
                       ▼
        ┌──────────────────────────────┐
        │ `pina build`                 │
        │ program compiles with the    │
        │ manifest + transitions       │
        │ embedded (include! bytes)    │
        └──────────────┬───────────────┘
                       ▼
        ┌──────────────────────────────┐
        │ `pina deploy`                │
        │ 1. pending record written    │
        │    atomically (versions      │
        │    frozen — may already be   │
        │    live)                     │
        │ 2. remote command runs       │
        │    (solana program deploy or │
        │    --remote-command)         │
        │ 3. immutable receipt with    │
        │    pinned schema history     │
        └──────────────┬───────────────┘
                       ▼
        ┌──────────────────────────────┐
        │ `pina generate`             │
        │ TypeScript, Rust, Dart, CPI  │
        │ clients embed the CURRENT    │
        │ version as an omitted        │
        │ constant — callers never     │
        │ pass a version               │
        └──────────────────────────────┘

Once a version appears in a receipt or pending record it is frozen: create appends the next version instead of rewriting it, and any edit to a pinned schema or transition hash fails every later check. A snapshot-only instruction has no next version, so its published payload is fixed.

Drift is decided by what a field stores, not by how its type is spelled. Respelling PodU64 as u64, or Address as [u8; 32], stores the same bytes under the same reading, so it consumes no version and fails no build, and the recorded spelling and pinned hashes stay as they were. Types that only share a width, such as u64 and i64, are a real change that needs a manual transition.

Instructions: a snapshot unless they opt in

An instruction covered by auto keeps its plain wire format, [discriminator][payload], and the manifest records exactly one version of it with "envelope": false. The snapshot is a gate, not a history:

  • The proc macro compares the struct with the snapshot and fails the build when they drift.
  • While nothing is published, pina migrations create replaces the snapshot with the current struct.
  • Once published, a payload change fails with PublishedPayloadChanged, because no byte tells the program which layout a client sent. Declare a new discriminator for the new payload, or restore the published fields.
  • Appending optional accounts to a published snapshot is allowed: create extends its process contract in place without consuming a version, and older clients simply omit the new slots.
  • A published snapshot cannot gain an envelope (EnvelopeAddition), because existing clients send no version byte. Declare a new discriminator for the migration-aware instruction.
  • An instruction that stops asking to be recorded (migrations = false, or an auto policy that no longer covers instructions) leaves a stale snapshot behind. pina migrations check fails with StaleSnapshot and create releases it; nothing on the wire changes.

An instruction that must keep accepting older payloads under one discriminator opts into full migrations with #[instruction(discriminator = X, migrations)]. It carries the envelope [discriminator][version][payload], and every later version has an adjacent transition at migrations/transitions/instruction_<width>_<hex>/vN_to_vN+1.rs. create never zero-fills an added instruction argument, because the handler could not tell that default from a value a client sent: a transition that adds an argument is scaffolded as a manual transition for you to fill in. A transition that only moves or drops bytes stays automatic.

An envelope is part of the wire format, so it can change only before publication. When the source stops asking for an envelope the manifest records, the macro fails the build: “removing an envelope is a wire-format change that pina migrations create must record deliberately”. Before publication, pina migrations create then records the instruction as a snapshot again; after publication it fails with EnvelopeRemoval. An explicit migrations = false on an enveloped contract is always rejected. A program published under ABI 0.20, where every recorded instruction was enveloped, keeps its wire format by adding migrations to each of those #[instruction] attributes.

Events: versioned, not migrated

Transaction logs are immutable, so an event is never converted. An event with migrations, or covered by auto, carries the envelope [discriminator][version][payload]. The program emits only the current version. Changing a published event’s schema appends a version with its own schema and writes no transition file; there is no migrations/transitions/event_* directory. Generated clients decode each version with that version’s schema, as described in Generated client helpers.

Persisted answers

Disambiguation answers do not have to travel as flags every run. A [migrations.answers] table in pina.toml persists them for the whole checkout:

[migrations.answers]
rename = ["value:points"]
assume_removed = []

create consults the table before prompting, command-line flags override it per field, and a flag that contradicts a persisted rename (for example --assume-removed value when the file renames value) fails closed. Fresh clones and CI therefore replay an answer made locally without anyone re-deriving flag lists, and --json failures print a machine-actionable envelope carrying the message plus the exact outstanding questions.

Runtime flow inside the program

This is the generated dispatcher’s decision tree for one invocation of an instruction that opts into migrations. An instruction recorded as a snapshot has no version to inspect: the dispatcher parses its payload directly, and only the account branch applies.

                transaction arrives
                        │
                        ▼
        ┌───────────────────────────────┐
        │ read discriminator, dispatch   │
        │ to the instruction             │
        └───────────────┬───────────────┘
                        ▼
        ┌───────────────────────────────┐
        │ inspect instruction version    │
        │ (one read after the disc)      │
        └───────┬───────────┬───────────┘
        current │    stale   │   future/corrupt
                ▼           ▼               ▼
      ┌──────────────┐ ┌──────────────────┐ ┌────────────────┐
      │ validate &   │ │ normalize:       │ │ reject now —   │
      │ run handler  │ │ zero workspace,  │ │ no trial       │
      │ (hot path:   │ │ walk adjacent    │ │ decoding       │
      │ no history   │ │ payload steps,   │ └────────────────┘
      │ scans)       │ │ validate each,   │
      └──────┬───────┘ │ commit current   │
             │         │ version marker    │
             │         └────────┬─────────┘
             │                  ▼
             │    ┌───────────────────────────────┐
             │    │ for each migratable account   │
             │    │ the route touches:            │
             │    │                               │
             │    │  version == current?          │
             │    │    yes → validate, done       │
             │    │    no  → PLAN                 │
             │    │      (validate exact source   │
             │    │       shape, owned detached   │
             │    │       state, no borrows)      │
             │    │          │                    │
             │    │          ▼                    │
             │    │    need growth & rent?        │
             │    │      yes → explicit capped    │
             │    │      payer transfers deficit  │
             │    │          │                    │
             │    │          ▼                    │
             │    │    RESIZE → APPLY (infallible)│
             │    │    → VALIDATE destination     │
             │    │    → WRITE version → re-      │
             │    │    VALIDATE                   │
             │    │          │                    │
             │    │          ▼                    │
             │    │    next adjacent version      │
             │    │    until current              │
             │    └──────────────┬────────────────┘
             │                   │
             └────────┬──────────┘
                      ▼
        ┌───────────────────────────────┐
        │ run current handler with      │
        │ current types only            │
        └───────────────────────────────┘

The version envelope supports `u8`, `u16`, and `u32` encodings (program-wide, `u8` by default) — never `u64`.

  failure BEFORE first mutation  → ordinary ProgramError
  failure AFTER first mutation   → instruction ABORTS (never a
                                   catchable error) so the transaction
                                   rolls back every resize, transfer,
                                   and byte write together

Accounts in one instruction migrate independently — a mixed set (Profile@v0, Journal@v1, …) each climb their own ladder atomically within the same invocation.

The payload normalization is generated, not written in the handler. #[discriminator(entrypoint)] routes every instruction the manifest records with an envelope through that instruction’s generated process_versioned, which converts a historical payload into the current layout and then calls ProcessAccountInfos::process_from_version(self, data, source_version). The default implementation forwards to process(data), so a handler reads data as the current layout. Override process_from_version only when the handler must tell a field a transition filled in from one the client sent. A program with a hand-written dispatcher calls <Instruction>::process_versioned(parsed_accounts, data) itself, and pina::normalize_instruction_data returns a NormalizedInstruction (as_bytes(), source_version(), was_migrated()) for manual use.

What is expected from each side

Client (generated)Program (generated + handler)
Version envelopewrites its frozen version automatically; caller never sees itreads it before any payload decode
Account datanever migrates, never rewrites; refuses to decode other versionsmigrates on demand, on-chain, in-transaction
Instruction payloadencodes args in its version’s shapenormalizes stale payloads of an instruction that opts into migrations; a snapshot-only payload never changes once published
Eventsdecodes each record with the schema of the version that emitted itemits only the current version
Rent for growthsupplies an explicit payer account when the instruction declares onetransfers only the deficit, capped, never refunds
Old clientskeep sending their old bytes unchangedare the reason the whole system exists
Breaking cases—fail closed: unknown/future versions, privilege changes, unprovable process changes are rejected or require a new discriminator

Paying for growth

Migration that only moves bytes is free beyond compute. Migration that makes an account bigger must also make it rent-exempt at its new size, and that lamports transfer happens inside the touching transaction:

  • the program transfers only the deficit (rent-exempt minimum at the new size minus what the account already holds), never more;
  • the payer is the instruction’s declared migration payer — a transaction signer, or one of the program’s own PDAs signing through invoke_signed;
  • the transfer is capped by the max_lamports budget the program passes to the executor. An undersized budget fails the whole transaction with MigrationLamportBudgetExceeded — nothing is half-migrated — but the account also stays stale until the budget is raised, so size it deliberately. The error names the budget to raise, and pina migrations create quotes the same deficit figure before you deploy.

A planning figure for that budget: rent exemption costs about 6,960 lamports per byte (3,480 lamports per byte-year at the two-year exemption threshold), so growing an account by N bytes needs roughly N × 6,960 lamports of head room on top of what the account already holds. pina migrations create prints a warning with the exact growth and estimate whenever a transition grows an account or keeps a compact (capacity-driven) layout, and adds the remedy for the failure the grown account would hit on chain.

The executor keeps the workspace, realloc-growth, and lamport budgets as separate codes, so each failure names one fix:

Failure conditionCodeRemedy
The stale account is more than MAX_INLINE_STEPS versions behindMigrationUnavailablePublish smaller, more frequent versions; migrate accounts before they fall further behind.
The normalization workspace cannot hold the generated transitionMigrationWorkspaceExceededKeep the generated workspace at or below MAX_MIGRATION_WORKSPACE (1,024 bytes).
One instruction would grow the account by more than MAX_PERMITTED_DATA_INCREASE (10,240 bytes)MigrationAccountGrowthExceededPublish intermediate versions so each transition migrates in a separate transaction; no budget raises this.
The rent deficit exceeds the program’s max_lamports budgetMigrationLamportBudgetExceededRaise the program’s max_lamports constant to cover the quoted deficit.

Builds before the split reported the workspace, account-growth, and lamport-budget failures as MigrationBudgetExceeded (0xFFFF_FFF5); MigrationUnavailable was already its own code and was not part of that split. The aggregate code stays reserved so published binaries remain decodable.

The growth check covers the whole supported ladder, not one hop: the executor captures the account size before the first step, so a v0 account walking two individually sub-limit transitions can still cross MAX_PERMITTED_DATA_INCREASE in one instruction. pina migrations create warns on that cumulative worst case, and publishing an intermediate version only resets the cap when it migrates in its own transaction.

Two consequences follow from the payer model:

  1. An old client cannot fund growth. A client generated before the instruction gained its optional migrationPayer slot submits without a payer; if the account it touches now needs rent, that transaction fails with MigrationRequired. The fix is a current client (or a payer-carrying migration path) — by design, rent is never taken from an account the client did not offer.
  2. The compute bill lands on the touching transaction. A stale account pays its ladder’s compute cost inside whichever transaction finds it, and MAX_INLINE_STEPS (at most 8 adjacent transitions) bounds how long that ladder may be. Accounts further behind than that fail with MigrationUnavailable rather than silently burning the budget; the remedy is rebalancing the history into more frequent, smaller versions. The reserved Migrate instruction below is the out-of-band route for carrying a payer, but it enforces the same step limit.

The reserved Migrate instruction

Pina reserves the all-ones value of every instruction discriminator width (0xff, 0xffff, 0xffff_ffff, 0xffff_ffff_ffff_ffff) for a framework migration instruction; #[discriminator] rejects user variants that claim it at compile time. A program with migratable accounts wires the reserved route before parsing its own instruction enum:

if is_migrate_instruction(data) {
    return process_migrate(program_id, accounts);
}

The trigger must match the program’s own discriminator width. is_migrate_instruction tests one byte, so a program whose instruction enum uses primitive = u16 (or u32, or u64) matches with is_migrate_instruction_u16 (or _u32, _u64) instead — the one-byte helper never matches a two-byte 0xffff, which would leave the reserved path unreachable. Generated entrypoints select the matching helper from the enum’s primitive, so #[discriminator(entrypoint)] needs no hand-written guard at all.

Its account layout is [payer, systemProgram, accountA, accountB, …]. Slot 0 is a writable payer funding every rent deficit (or the program address when the invocation needs no funding), slot 1 is the system program the rent transfers invoke, and each later slot is a program-owned, self-describing migratable account.

The ladder is derived, not declared. #[discriminator(entrypoint)] wires the route on its own: the slots are read from migrations/manifest.json, one per enveloped account contract, in the manifest’s identity-sorted order — exactly the order generated clients compose. Declaring a contract list is optional and only needed to batch several accounts of the same contract in one sweep, because the manifest records contracts rather than account instances:

The route calls the resize executor, so a program that serves migrations needs pina’s account-resize feature. pina init scaffolds it; the generated code names it when it is missing.

#[discriminator(entrypoint)]
pub enum Instruction { /* … */ }

// Optional ceiling, and optional slot override for same-contract batching.
#[discriminator(
	entrypoint,
	migrations(State, State),
	migrations_max_lamports = MAX_INLINE_MIGRATION_LAMPORTS,
)]

migrations_max_lamports is optional and off by default. Declaring one caps the total lamports the reserved instruction may transfer; leaving it out enforces no ceiling, which is safe because a transfer is never more than the rent deficit of a growth the runtime already caps at MAX_PERMITTED_DATA_INCREASE. Declare one to refuse an expensive migration rather than to permit it. A slot holding the program address (the placeholder generated clients write for an omitted optional account) or an index past the end of the list is skipped, so a client sends only the accounts it needs. MigrateContext validates ownership, rejects duplicated account slots, migrates each slot at most once, and runs each slot through the same MigrateAccount executor — the same step, growth, and lamport caps as the inline path.

That makes the client flow explicit: when an account is stale and the business instruction cannot carry a payer, prepend [Migrate { payer }, …real instructions] in the same transaction — the payer authorizes exactly the migration cost, and the real instruction observes current data or the whole transaction fails.

Reading without migrating

Migration is a mutation, and the runtime forbids writes and resizes on a read-only account. A program that only reads a stale account therefore has no migration path in that instruction. Generated account code now offers a read-only alternative: <Account>::try_from_bytes_versioned(bytes) validates the exact representation named by the version envelope and borrows it immutably. It never rewrites, resizes, or clears anything, and it never requires a writable borrow; foreign discriminators, unknown versions, future versions, and malformed lengths all fail closed.

The generated <Account>Versioned enum has one variant per historical version plus Current, so the caller must handle every stored representation:

let data = account.try_borrow()?;
match State::try_from_bytes_versioned(&data)? {
    StateVersioned::V0(view) => read_v0(view),
    StateVersioned::V1(view) => read_v1(view),
    StateVersioned::Current(view) => read_current(view),
}

This is a deliberate trade-off, not a replacement for migration. A program that reads historical layouts must handle two (or more) representations in its business logic, which is exactly the branching the migration system exists to remove. Treat the view as the complement for read-heavy accounts whose one-time writable touch is genuinely hard to schedule; the reserved Migrate instruction remains the primary fix. The accessor is generated only for accounts declared with migrations.

Generated client helpers

Generated clients turn that flow into a one-call routine. Next to each migratable account module the TypeScript, Dart, and Rust clients emit:

  • a <Account>MIGRATION_VERSION constant — the schema version the client was generated from;
  • the generic needsMigration envelope check is that per-account helper (stateNeedsMigration for a State account, and so on);
  • <account>NeedsMigration(bytes) — a cheap envelope check that returns true only when the bytes name this account’s discriminator and a version older than the client’s schema. Future versions and foreign discriminators return false; the decoder explains those when the account is decoded.

The clients also emit a Migrate instruction composer (TypeScript getMigrateInstruction, Dart getMigrateInstruction, Rust Migrate::new().instruction()). In the Migrate composer, the payer and system program slots are always sent, and systemProgram defaults to the system program because the program rejects anything else in slot 1. Every migratable slot is optional: omitted slots become program-address placeholders and trailing omitted migratable slots are truncated, so a client sends only the accounts it needs. The intended catch → migrate → retry loop:

const account = await fetchEncodedAccount(rpc, address);
if (account.exists && stateNeedsMigration(account.data)) {
	// `getMigrateInstruction` returns a complete instruction; add it to a
	// transaction message like any other.
	await send(getMigrateInstruction({ state: address, payer }));
}
// now decode `state` and send the real instruction

migrateIfNeeded — fetching, checking, and migrating in one call over an RPC handle — is designed in ADR 0008.

Versioned events are decoded, never converted. The IDL lists each earlier version of an event as its own event node named <Event>V<n> (for example ValueChangedEventV0), with that version’s schema and codec, next to the current event. The program-level log parser (parse<Program>EventsFromLogs in TypeScript and Dart) dispatches each record by discriminator and version to the matching event and throws on a version no generated event describes — for example event "valueChangedEvent" log carries migration version 2, which this client cannot decode; regenerate it. A decoded record carries that version’s own fields: nothing is projected into the current shape or zero-filled. In Rust, each version’s try_from_bytes tells a stale record from a future one and names the event generated for the other version. The parser also attributes each line to the program that emitted it by following the transaction’s invoke/success frames, so pass it a transaction’s complete, ordered logs. Clients render these decoders from the IDL alone; the migration manifest is read only when the IDL is generated.

The sweep instruction

A typed reader refuses a stale account: as_account, as_account_mut, and validate_account_data all inspect the version envelope and return MigrationRequired for anything older than the program’s current schema. The refusal happens wherever the handler loads the account, and it does not care whether the account was passed writable or read-only. Migration itself does care: MigrateAccount asserts writability and program ownership before it inspects a single byte, because the executor may rewrite, resize, and fund the account. An instruction that treats a migratable account as read-only therefore has no way to bring it current, and keeps failing until some other transaction migrates the account once. The sweep instruction is that other transaction: one instruction whose only job is putting migratable accounts in a writable position and running the executor on each.

The instruction

Write one sweep per program with its own discriminator. Every migratable account appears as an optional, writable view, the payer funding growth appears once, and the system program the rent transfers invoke is declared:

#[discriminator]
pub enum SwapInstruction {
	Swap = 0,
	SweepAccounts = 1,
}

#[derive(Accounts)]
pub struct SweepAccounts<'a> {
	/// Writable signer funding every rent deficit, usually the fee payer.
	#[pina(validate(signer))]
	pub payer: Option<&'a mut AccountView>,
	/// The system program every growth transfer invokes.
	pub system_program: &'a AccountView,
	/// Every migratable account this program owns, writable and optional.
	pub state: Option<&'a mut AccountView>,
	pub profile: Option<&'a mut AccountView>,
	pub vault: Option<&'a mut AccountView>,
}

impl<'a> ProcessAccountInfos<'a> for SweepAccounts<'a> {
	fn process(self, _data: &[u8]) -> ProgramResult {
		let SweepAccounts {
			payer,
			system_program,
			state,
			profile,
			vault,
		} = self;
		system_program.assert_address(&system::ID)?;
		let payer = payer.map(|account| &*account);

		if let Some(state) = state {
			MigrateAccount {
				account: state,
				payer,
				program_id: &ID,
				max_lamports: MAX_MIGRATION_LAMPORTS,
			}
			.invoke::<State>()?;
		}
		// The same call for profile and vault: one independent invoke each.

		Ok(())
	}
}

The handler is a straight line of independent calls. Each invoke::<T>() is complete on its own: it validates the account, plans the adjacent transitions, and commits the current version. An absent account arrives as None and the call is skipped, so nothing is validated, charged, or written for it. A missing middle slot still needs the program-address filler the account parser expects; trailing absent slots can be omitted.

The payer must be writable and a transaction signer, or a PDA signing through invoke_signed. max_lamports caps what one call may transfer from it, so a sweep pays at most the sum of its calls’ caps — set each one deliberately.

The client sends it first

Prepend the sweep, then send the real instruction in the same transaction:

[sweep(state, profile, payer), swap(...)]

First, not last: instructions run in order and any failure aborts the whole transaction. The real instruction loads the stale account and fails with MigrationRequired, so a sweep placed after it never runs. The sweep can carry every account the transaction might touch; the real instruction then observes current bytes, or the whole transaction — migrations included — rolls back. A sweep sent alone, with no business instruction, is a maintenance transaction that pre-migrates accounts ahead of future use. That standalone form is the only trailing sweep worth sending.

What happens per account

Account stateWhat the sweep call does
AbsentSkipped by the handler; nothing is validated, charged, or written
Already currentOwnership, writability, and discriminator checks, a version-envelope inspection and current-layout validation; no writes, no rent, no CPI — safe to send speculatively
Stale, same sizePlanned from the exact historical layout, rewritten in place, destination validated, current version committed
Stale, growingThe payer transfers only the rent deficit for the size the step needs, capped by max_lamports; the account is resized, rewritten, and the version committed
Stale, shrinkingRewritten and shrunk; every lamport stays in the account, there is no refund today
Any step failsThe instruction fails and the transaction rolls back, including sweeps that already completed in the same instruction

The failures are sharp and in front of the first mutation:

  • a growing step with no payer fails with MigrationRequired; a deficit above max_lamports fails with MigrationBudgetExceeded — the account stays stale, nothing is half-migrated;
  • a read-only or foreign-owned account fails the writability or ownership assertion before the version is even read, and a wrong discriminator fails with InvalidAccountData;
  • a version newer than the program’s current schema fails with InvalidMigrationVersion instead of guessing at the layout.

Why it is safe to expose

Every account is owner-checked and writability-checked before anything is inspected. Transition planning validates the exact historical shape and accepts only adjacent steps the published history proves; the destination representation is validated before the current version marker is committed, and an invariant failure after the first byte or lamport moves aborts the instruction instead of returning an error the caller could catch. The payer can only add lamports — the deficit, never more than max_lamports — and shrinking never moves lamports at all. Because each caller supplies the payer it wants to charge, a sweep cannot spend an account its sender did not offer: nobody can be made to fund someone else’s migration.

Forward compatibility

The reserved Migrate instruction above is the framework-owned instance of this pattern. MigrateContext runs the same [payer, systemProgram, …accounts] layout through the same MigrateAccount executor, and run_optional treats a slot holding the program address — or an index past the end of the list — as absent, so it is also a sweep. Generated clients emit a Migrate composer and the per-account needsMigration checks, so the standard sweep needs no hand-composed metas; the one-call migrateIfNeeded wrapper is tracked in #339. A hand-written sweep stays compatible with that route because it drives the same executor and produces exactly the same account state. Prefer it when the handler needs policy the generated route does not carry — extra gates, per-account caps, or a different payer rule — and prefer the reserved instruction when it does not.

The four scenarios

1. Current client → current program (the hot path)

client encodes v_N payload ──► program sees version == current
                                   │
                                   ▼
                          validate current shape
                                   │
                                   ▼
                          handler runs — zero
                          migration work done

For an enveloped contract, one version comparison is the entire overhead. A snapshot-only instruction has no version to compare.

2. Old client → updated program (the migration path)

old client encodes v_0 payload
        │
        ▼
program sees 0 < current ──► stale
        │
        ▼
normalize payload: v_0 → v_1 → … → v_N
(zeroed workspace, each step validated,
 current marker committed last)
        │
        ▼
migrate every stale account the route touches
(plan → fund → resize → apply → validate → commit,
 one adjacent step at a time)
        │
        ▼
current authorization + business logic run
with current types
        │
        ▼
success: one transaction did everything
failure anywhere: everything rolls back

The old client cannot tell that anything happened. This is the property the whole design protects: backwards compatibility is the default, and the program owns it.

The payload step runs only for an instruction that opts into migrations. A snapshot-only instruction cannot change its payload after publication, so an old client’s request is already in the current layout and only the account steps apply.

3. Old client → rolled-back program (the honest limit)

Rolling back the binary to a previous executable does not roll back accounts. Accounts written by version N carry N; a program compiled when current == N-1 sees those as future versions and rejects them without trial decoding. The supported rollback is redeploying an executable built from the current manifest history (an old binary with the current ABI), which keeps every live account readable. This is a deliberate security stance, not an implementation gap: silently guessing at newer layouts would let a rolled-back program misinterpret post-rollback data.

4. Where the migration lives: program or client?

Implemented: the program. Inline, on-demand, per-account — the transaction that touches a stale account performs its migration before the handler runs, funded by the payer that transaction already declared.

Implemented: the reserved prefix. A program wires the reserved Migrate instruction (see “The reserved Migrate instruction” above) so a client can prepend [Migrate { payer }, …real instructions] when an account is stale and the business instruction declares no payer. Still designed, not built (ADR 0008, tracked in #339): the generated migrateIfNeeded helper that fetches accounts, compares the version constant it already embeds, and prepends the migration only when needed. Until that ships, any instruction that can touch a migratable account during a growth step must also declare an optional migration_payer slot, and current clients pass it.

Version exhaustion

Run out of versions and nothing can fix it afterwards. Two facts decide how much this matters.

Versions are counted per contract, not per program. Every account, instruction, and event owns an independent history that starts at 0, keyed by its own discriminator in migrations/manifest.json. A snapshot-only instruction never consumes a version. A program can hold one account at version 3 and another still at 0; they do not share a counter, and exhausting one says nothing about the rest. So the budget is “255 versions of this one contract”, not “255 versions of the program”.

The width is program-wide and freezes at the first publication. pina migrations create --version-type records one width for every contract as versionType in the manifest, and a later run without the flag keeps it. Before the first release the width is still yours to choose: pina migrations create --version-type u16 rewrites it in place, because every history is still a single draft. Once a receipt or pending record exists the width is frozen, and the flag fails with “Migration version encoding is frozen as u8 because a deployment published it, so it cannot become u16”. After the first release there is no widening path.

That combination makes u8 the right default. 255 versions of a single account type is not a realistic lifetime for a program that migrates sensibly, and it costs one byte per enveloped account; u16 costs two and is worth choosing up front only if you expect a single contract to exceed 255 revisions.

pina migrations status reports the remaining budget per contract so drift toward the ceiling is visible:

account State v3 (published, 252 version(s) remaining)
instruction InitializeInstruction (published, snapshot without envelope)

When a contract does reach its ceiling

pina migrations create fails closed with VersionExhausted rather than wrapping. There is no silent reuse of version numbers, and the on-chain side rejects out-of-range versions via try_from_u32 instead of truncating, so a wrapped version can never be written or misread.

The remedy is a successor contract, not a larger counter:

  1. Declare a new discriminator variant for the successor account and give it a fresh version-0 history. It is a new contract identity, so it gets its own full budget.
  2. Add a bridge instruction that loads the exhausted account through the current loaders and writes the successor account, then closes or drains the old one.
  3. Migrate accounts lazily: the bridge runs once per account, driven by a client sweep, the same way the reserved Migrate route works.

Old history cannot be pruned to make room. A program cannot enumerate its own accounts — Solana offers no such primitive — so retiring history requires an external proof that every account of that type has been converted. Plan the successor before the ceiling, because the bridge needs the old layout to still be readable by the binary you ship.

Seeing it live

The whole of this page runs for real in the ten-deployment walkthrough:

pnpm walkthrough:migrations            # full run, ~3 minutes
pnpm walkthrough:migrations -- --from-step 6 --to-step 8 --keep

It scaffolds a program, evolves it through ten published generations (add field, rename through the question flow, type change via a manual transition, compact growth, payload growth, appended optional accounts, event growth, acknowledged removal, full-ladder replay), deploys each one to an isolated Surfpool network through pina deploy, regenerates every client each time, and proves that step-1 clients keep submitting successfully against the step-9 program with their data preserved.

ABI document versioning

Pina’s ABI document is the checked-in migrations/manifest.json and the publication ledger at migrations/publications.json. Both carry an abiVersion that belongs to Pina itself and has nothing to do with any user contract’s on-chain version.

This page covers how that version is chosen, how older documents are normalized, what the document stores versus derives, and how its JSON Schemas are generated and published. For the runtime decision tree that governs on-chain migrations, read How ABI migrations flow. For the decision record behind this design, read ADR 0009.

Three version axes

A Pina program carries three unrelated kinds of version, and confusing them is the most common source of migration bugs.

AxisExample valueWho owns itChanges when
On-chain contract version3 (integer in the envelope)Pina, allocated by createan enveloped contract’s published schema changes
ABI document abiVersion"0.21"pina_abi’s own release linea breaking pina_abi release — and nothing else
pina_abi package version0.21.3the release plannerany pina_abi release, including patches

Only the first one is written into account bytes. An ABI document upgrade never consumes an on-chain migration version, and an on-chain migration never changes abiVersion.

pina_abi is not part of the core release group. It releases on its own single-member group, so a CLI fix or a renderer tweak in core moves nothing here, while any pina_abi release cascades into a core release because pina_cli and pina_macros consume it. The contract moves deliberately and consumers follow; core releases never move the contract.

The version value

abiVersion is a committed value, one line of text in crates/pina_abi/ABI_VERSION, embedded with include_str!:

pub const ABI_VERSION: &str = include_str!("../ABI_VERSION").trim_ascii_end();

The file is pinned to pina_abi’s major.minor and is allowed to lead the crate. A shape-changing pull request advances the file while Cargo.toml still reads the old version — the pre-release window — and the release planner’s bump catches the crate up. This is the monochange model: a committed SCHEMA_VERSION that can sit ahead of its crate (verified at 0.7 against a crate at 0.6.3), which a compile-time derivation of CARGO_PKG_VERSION cannot express. monochange tried derivation twice and reverted both times; ADR 0009 carries the history and the reasoning.

Pre-1.0 bump semantics

While the major is 0, the release planner shifts bump severity: a breaking changeset advances the minor, a feat or fix advances only the patch. Since abiVersion keeps major.minor, only a breaking changeset moves it:

changeset on pina_abicrate versionabiVersion
breaking0.20.0 → 0.21.0"0.21"
feat0.20.0 → 0.20.1"0.20"
fix0.20.0 → 0.20.1"0.20"

The version moves if and only if the contract moved. This property is why pina_abi stays pre-1.0: at 1.0.0 the shift switches off, a feat would advance major.minor with no wire change, and 1.0.0 is not reachable through the planner anyway. Every 0.N version also keeps a permanent schema URL under the publishing policy, and every ABI version is a real contract worth one.

Reading a document

Every reader goes through the decode path rather than deserializing straight into the typed model:

  1. parse the JSON value;
  2. read abiVersion and reject a document stamped above the reader’s own version, naming the supported version in the error;
  3. apply each adjacent converter in order, from the document’s version up to the current one;
  4. deserialize into the current typed model;
  5. validate every content-addressed invariant, re-deriving everything the document no longer stores.

Step 2 is a capability marker, not a compatibility promise, and behaves like Cargo.lock’s version field: a repository’s documents are read by the same Pina the repository builds with, so the rejection only reaches a human who downgraded, and the remedy is one line — upgrade.

Conversions run in memory only. No command rewrites a checked-in document as a side effect of reading it.

The converter contract

The reader carries an ordered table of adjacent converters — one edge per ABI version step — keyed by the version itself. There is no separate epoch table: the version advances exactly at contract changes, so it is the only axis. Both documents share one abiVersion, so each step carries a converter for each document, and walk_document(AbiDocument::Manifest | AbiDocument::Publications, from, value) applies the matching one and stamps the step’s to version on the result:

pub const ABI_STEPS: &[AbiStep] = &[AbiStep {
	from: "0.20",
	to: "0.21",
	manifest: convert::manifest_0_20_to_0_21,
	publications: convert::publications_0_20_to_0_21,
}];

Four rules make the chain trustworthy.

Every step has an edge, including no-ops. The version follows every breaking pina_abi release, but the document shape changes only some of those times. A breaking change with an unchanged document registers a validating no-op edge that decodes, validates, and returns the document unchanged. monochange’s v0.6 → v0.7 edge is the precedent — payloads unchanged, version advanced because a config contract changed — and the edge exists so the 0.6 contract stays frozen while the walk stays gapless.

Forward only. Writers always emit the current version, and a document that must reach an older tool is regenerated from source by that tool. A downgrade entry point does not exist, so no best-effort write can lose information.

Never deleted. Once an edge ships, it is part of published history. Fixing a defective converter means a new version, never editing the old one — the same immutability rule the on-chain migration system applies to its transitions.

Gapless by construction and by test. The table is an ordered walk over adjacent versions, and a test decodes every frozen fixture to the current model, so a missing or mis-wired edge is a CI failure rather than a runtime error on a user’s machine.

Stored versus derived

The 0.20 baseline stored facts and re-derived everything else at load, and 0.21 removed the remaining stored copies. What the document used to carry, and where it comes from now:

Removed fieldWhere it comes from now
ContractHistory.identity (0.21)the contracts key kind:width:hex, parsed on load
DataSchema.codec (0.21)implied by abiVersion (pina_abi::SCHEMA_CODEC)
event Transition entries (0.21)none — events are decoded per version, never converted
"transition": null (0.21)an absent transition key
ProcessAccount fields at their default (0.21)an absent writable, signer, or optional reads as false, and an absent defaultValue or pda as none
receipt sequence (0.21)the receipt’s position in receipts
receipt and pending programId (0.21)the manifest’s programId: the ledger belongs to the manifest beside it
receipt and pending manifestSha256 (0.21)not recorded — it hashed a manifest later versions rewrite, and nothing compared it
receipt and pending previousReceiptSha256 (0.21)not recorded — version control keeps the ledger append-only
per-contract version in a receipt or pending (0.21)the position of the contract’s last pin
DataSchema.physicalderived from layout and fields by the frozen grammar
SchemaVersion.versionthe version’s position in the versions array
SchemaVersion.schemaSha256computed from the decoded schema
SchemaVersion.processSha256computed from the decoded process
Transition.from / toadjacency — the transition sits on version index from index-1
Transition schema/process hashesthe neighbouring versions’ computed hashes
Transition.process proofre-derived by classify_process_transition
ProcessAccount.constraintsnot recorded — validation rules live in the IDL clients use
receipt clusternot recorded — rpc_url keeps the credential-free endpoint

What stays is what cannot be derived: rust_name (macro lookup), the auto policy, envelope (written only as "envelope": false, on an instruction recorded without one), identity validation including the path-traversal proofs, mode, renames, implementation_sha256 (the hash of an external transition file), and the publication ledger: each receipt’s credential-free rpcUrl, its executableSha256, the pinned schema history of every contract it made live, and its abandoned flag, plus the pending record’s cluster. Publication receipts pin schema hashes by computing them from the decoded manifest at pin time.

A field’s rustType is stored as the source spelled it when the version was recorded. Drift is decided with DataSchema::same_wire, which compares the layout, the field names, and each type after pina_abi::wire_type collapses spellings that store the same bytes under the same reading (PodU64 and u64, Address and [u8; 32], PodString<N> and String<N>, PodVec<T, N> and Vec<T, N>). A later respelling therefore neither rewrites the recorded spelling nor changes a pinned hash.

The wire codec left the stored schema but not the schema hash. schemaSha256 still hashes layout, fields, and "codec": "pinaPodV2" in the order 0.20 serialized them, so every published pin keeps its value. A PinaPod release that changes wire bytes changes SCHEMA_CODEC through a breaking pina_abi release, and the converter for that release records the old format on every historical schema it carries forward.

Because deny_unknown_fields applies, any field change is a breaking change for older readers, and every document change therefore advances the ABI version through a breaking changeset. The converse does not hold — a breaking crate change with an unchanged document is the no-op edge above.

The frozen fixture matrix

crates/pina_abi/fixtures/0.20/manifest.json
crates/pina_abi/fixtures/0.20/publications.json
crates/pina_abi/fixtures/0.20/manifest.schema.json
crates/pina_abi/fixtures/0.20/publications.schema.json
crates/pina_abi/fixtures/0.21/…
crates/pina_abi/fixtures/current/…
  • A <version>/ directory is frozen when that release ships and is never regenerated. They are the only artifacts that record what an older release actually wrote; a fixture regenerated from current code would encode today’s shape under yesterday’s version and prove nothing.
  • current/ is regenerated by the schema and fixture task whenever the model changes.
  • Fixtures are generated deterministically from fixed seeds, the way the monochange schema assets are, so the frozen bytes are reproducible on every machine rather than hand-maintained.
  • The fixtures prove the shape changed; the committed value only names it. The fixture-drift guard pairs the two: regenerating current/ must reproduce the frozen bytes unless ABI_VERSION advanced. A serialization change without a version change fails CI; a version change with unchanged bytes is the deliberate no-op edge, which registers a new frozen directory with identical bytes.
  • A test decodes every frozen fixture through the current reader and asserts it reaches the current model — the gapless-walk guard.
  • The not-lag guard asserts the committed value never lags the crate’s major.minor, and the ahead-requires-changeset guard asserts that a value ahead of the crate — the pre-release window — has an active pina_abi changeset behind it. Any other ahead state fails.

JSON Schemas

The document types derive schemars::JsonSchema, so the schema is generated from the same types that serialize and deserialize the document — deny_unknown_fields becomes additionalProperties: false, and the schema can never drift from the code that enforces it.

  • Canonical artifacts are checked in at crates/pina_abi/schemas/manifest.schema.json and publications.schema.json, regenerated and drift-checked in CI the way Codama IDL fixtures are.
  • Each version’s schema is frozen inside its fixture directory, so the schema for any historical shape stays printable.
  • Every version’s schema is hosted at a permanent URL under this book: https://pina-rs.github.io/pina/abi/schemas/<version>/manifest.schema.json, with $id set to that URL. Every 0.N version keeps its URL permanently.
  • pina abi schema [--document manifest|publications] [--version <major.minor>] prints the JSON with stable output, so editors, CI jobs, and non-Rust toolchains can validate a document without trusting the bytes:
pina abi schema --document manifest > manifest.schema.json

Upgrading from ABI 0.20

A 0.20 document still opens: the reader converts it in memory, and the next pina migrations create writes it at 0.21. The one exception is a ledger entry that names a published version without pinning it, covered below. The manifest step:

  • drops each history’s identity object after proving that its kind, width, and hex spell the contract key;
  • drops each schema’s "codec": "pinaPodV2" after proving it is that codec;
  • drops every event transition, because an event is decoded with the schema of the version that emitted it;
  • keeps every instruction enveloped, because every 0.20 instruction history was.

The publication ledger step drops every stored copy of a fact the ledger or its manifest already records:

  • drops each receipt’s sequence after proving it equals the receipt’s position;
  • drops programId from every receipt and the pending record after proving they all name the same program;
  • drops each contract’s version after proving it is the position of its last pin, leaving the entry as the bare list of pins;
  • drops manifestSha256 and the previousReceiptSha256 chain link unchecked, because nothing compared the one and anyone able to edit the file could recompute the other;
  • drops the pinned transitionSha256 of every event:* contract.

Every schemaSha256 pin keeps its value. A 0.21 entry must pin every version it made live, so a 0.20 entry that pinned nothing cannot convert, and reading it fails with an error naming pina migrations reconcile --pin-legacy. Confirm from version control that migrations/manifest.json still records exactly what those receipts shipped, then run that command once: it pins each such entry from the manifest (trust on first use) and writes the ledger in the 0.21 shape.

Five source changes can follow the conversion:

  1. Instructions recorded through auto. An instruction that 0.20 recorded only because auto covered instructions stays enveloped after conversion, but under 0.21 an auto policy alone records an instruction without one, so the build fails: “the migration manifest records UpdateInstruction with a version envelope, but it no longer opts in with the migrations token”. If the instruction is published, add migrations to its #[instruction] attribute to keep its wire format; pina migrations create rejects the other direction with EnvelopeRemoval. If nothing is published, you may instead run pina migrations create, which records the instruction as a snapshot without a version byte; delete its migrations/transitions/instruction_* directory afterwards, because nothing reads it.
  2. Event transitions. migrations/transitions/event_* directories are no longer read or verified. Delete them. Code that called normalize_event_data, with_current_event_data, MigratableEvent, or CurrentEventData must decode a historical record with the generated client’s <Event>V<n> event instead.
  3. Instruction handlers. #[discriminator(entrypoint)] now normalizes an enveloped instruction’s payload before the handler runs, through the generated process_versioned and ProcessAccountInfos::process_from_version. Remove handler-side calls to with_current_instruction_data: the payload is already current, and the helper’s closure now takes the source version as a second argument, so existing calls no longer compile.
  4. pina.toml migration keys. [migrations].version_type (or version-type) and [migrations].auto are retired, because the manifest already records both. Every command that reads pina.toml fails until they are removed, and the error names the pina migrations create --version-type or --auto value that records the same setting. [migrations.answers] stays.
  5. TypeScript event decoders. The generated per-event decoder is decode<Event>Event (for example decodeValueChangedEventEvent and decodeValueChangedEventV0Event), matching the Dart clients. It decodes one version and converts nothing; rename calls to the old normalize<Event>Event.

Upgrading from pre-0.20 integer formats

The integer formatVersion era is retired, not migrated. Migrate to the reset ABI document walks the conversion. After upgrading Pina:

  1. run pina migrations sync (or pina migrations create) in the program directory — every checked-in draft is regenerated at the current abiVersion;
  2. commit the rewritten manifest.json and publications.json.

A ledger that pinned a real deployment would have no upgrade path from the integer formats. None exists today — the only non-empty receipt in any example records the fixture cluster — and accepting that one-time stranding is recorded in ADR 0009.

What this does not cover

This mechanism governs the document. It is not the on-chain migration system:

  • it does not read or write account bytes;
  • it does not allocate or consume a contract version;
  • a broken converter cannot corrupt an account, because no account data passes through it.

The publication ledger records no manifest digest. A 0.20 receipt carried a manifestSha256, but it hashed a manifest that later versions rewrite and nothing compared it, so 0.21 drops it. What a receipt proves comes from its pins instead: the schema and transition hashes of every version it made live, which every later check compares with the checked-in manifest. ADR 0009 records the decision.

The on-chain rules — envelopes, adjacent transitions, rent, the publication ledger, and the four scenarios — are in How ABI migrations flow.

Checklist for an ABI-affecting release

  1. Change the document shape, or the pina_abi contract. deny_unknown_fields means every document change is breaking; there are no additive-only changes.
  2. Advance crates/pina_abi/ABI_VERSION to the next minor. The value leads the crate until the release bump catches up.
  3. Add the adjacent converter edge — a real one when the bytes changed, a validating no-op when they did not.
  4. Add the breaking changeset on pina_abi. The ahead-requires-changeset guard fails without it.
  5. Regenerate the canonical schemas and the current/ fixtures, freeze the outgoing version’s fixture directory, and copy the new version’s schemas into the hosted location.
  6. Run pina migrations sync so every checked-in example is rewritten at the current version, and confirm the current/ fixtures changed — or, for a no-op edge, did not.
  7. Run pina migrations check and the pina_abi suite. The not-lag, ahead-requires-changeset, fixture-drift, and gapless-walk tests are the four gates that must pass.

Migrate value rules to comparisons

Value rules in #[pina(validate(...))] are now comparisons over the field’s value or its len, so the annotation reads as the check it generates. The min, max, min_len, max_len, and exact_len parameter spellings are deprecated: they still parse and generate the identical checks, but each one warns at the parameter and names its replacement.

Nothing on the wire changes. Value rules are not recorded in the ABI document or migration manifests, and the deprecated spellings generate the same checks their replacements generate, so migrating an annotation is a rename, not a release.

Rewrite each named bound as a comparison

BeforeAfterCheck it generates
min = 1value >= 1inclusive lower bound
max = 10value <= 10inclusive upper bound
min_len = 2len >= 2inclusive minimum byte or element count
max_len = 64len <= 64inclusive maximum byte or element count
exact_len = 4len == 4exact byte or element count
#![allow(unused)]
fn main() {
// before
#[pina(validate(min = 1, max = 1_000_000, error = TransferError::InvalidAmount))]
pub amount: u64,

// after
#[pina(validate(value >= 1 && value <= 1_000_000, error = TransferError::InvalidAmount))]
pub amount: u64,
}

Chain bounds on one receiver with && and separate rules with ,. A chain may also put the receiver in the middle, mirroring the range it checks:

#![allow(unused)]
fn main() {
#[pina(validate(100 < value <= u64::MAX))]
pub amount: u64,

#[pina(validate(4 < len <= 100))]
pub memo: String<100>,
}

Comparisons also express what the named bounds could not. Use value != 0 for a declarative non-zero check, or len != EXPR to reject one specific length.

What keeps its single =

Named parameters that are not comparisons keep their spelling:

  • error = ERROR names the failure to raise, not a comparison. It stays in the validation group it overrides.
  • validate(with = function) in the outer macro is a hook parameter and keeps =.
  • Account rules in #[derive(Accounts)] — signer, address = EXPR, owner = EXPR, owners = EXPR, program = EXPR, sysvar = EXPR, data_len = EXPR, distinct_from = FIELD, empty, not_empty, writable, executable — are unchanged. Only value rules over value and len moved to comparisons.

Read the deprecation warning

Each deprecated parameter warns at the token the author wrote, which is the ordinary deprecated lint: #[allow(deprecated)] silences it and -D warnings fails on it.

warning: use of deprecated constant `_::PINA_DEPRECATED_VALIDATION_BOUND`: `min` is deprecated;
write the bound as a comparison, as in `value >= 1`
 --> src/lib.rs:8:22
  |
8 |     #[pina(validate(min = 1, max = 10))]
  |                      ^^^

The warning lowers to an empty const block, so it costs no compute units and adds no bytes to the program.

Type gates and duplicates carry over

A comparison is subject to the same gates its named bound was: value rules require an integer field (u8 through u128, i8 through i128, or the Pina Pod* integer types), and len rules require String<N>, PodString<N, PFX>, Vec<T, N>, PodVec<T, N, PFX>, or [u8; N]. Duplicates and the exact_len conflict are diagnosed the same way, spelled for the new grammar where the rule that triggered them is new.

See Declarative Validation for the complete rule table.

Migrate to the reset ABI document

Pina 0.20 replaced the ABI document’s two integer counters with one abiVersion pinned to the pina_abi release line, and removed every derived field from the document. The old documents are not readable by the new release — not rejected as unsupported, but unparseable — so a program with checked-in migration documents must convert them once.

Your on-chain bytes are untouched. Discriminators, version envelopes, PinaPod payloads, and generated clients are identical before and after. This migration rewrites JSON files on disk; no account data moves and no instruction changes. If your program never enabled migrations, there is nothing to do.

The design is recorded in ADR 0009, and the versioning rules it introduced are in ABI document versioning.

The current release writes abiVersion 0.21. The conversion below still produces 0.20 documents. Step 3 rewrites the ledger at the current version, and every reader converts the manifest in memory until the next pina migrations create writes it at the current version. 0.21 also changed how instructions and events are recorded, so read Upgrading from ABI 0.20 once this guide is done.

The symptom

After upgrading, the first Pina command that reads a migration document fails:

error: Could not decode migration file migrations/manifest.json: migration manifest is
missing a string `abiVersion` field

That is the old document. The new reader fails closed on it because every removed field is rejected by deny_unknown_fields, and the version key itself was renamed. There is no converter from the integer formats: they described a document shape nothing external ever consumed, so the reset retires them instead of migrating them.

Choose a path

SituationPath
migrations never enablednothing to do
Enabled, program never deployed, draft history is disposableStart fresh
Enabled and deployed, or history worth keepingKeep your history

A program that was never deployed can always start fresh: version numbers have no on-chain meaning until an account is written. A deployed program must keep its history — accounts on chain carry the version numbers your manifest recorded, and resetting that history makes every existing account read as a future version the program refuses to load.

Either way, first delete version_type (or version-type) and auto from the [migrations] table of pina.toml, keeping any [migrations.answers]. Current releases record the version width and the auto policy only in the manifest and refuse both keys, naming the pina migrations create flag and value that record the same setting.

Start fresh

From the program directory:

rm -rf migrations
pina migrations create --no-interactive --auto true --version-type u8
pina migrations check

Pass the --auto and --version-type values your pina.toml used to set, or omit either flag for no auto policy and u8 versions. create rebuilds the manifest from source at the current abiVersion, regenerates tests/abi_layout.rs, and recreates the publication ledger on your next deploy. Draft version history collapses to version zero — which is exactly why this path is for programs nothing has been deployed to yet.

Keep your history

Two edits and one repair command, then the normal verify loop. Work from the program directory.

1. Convert the manifest

Save this as convert-manifest.jq:

del(.formatVersion)
| .abiVersion = "0.20"
| .contracts |= with_entries(
	.value.versions |= map(
		del(.version, .schemaSha256, .processSha256)
		| .schema |= del(.physical)
		| .transition |= del(
			.from,
			.to,
			.sourceSchemaSha256,
			.destinationSchemaSha256,
			.sourceProcessSha256,
			.destinationProcessSha256,
			.process
		)
		| if .process then .process.accounts |= map(del(.constraints)) else . end
	)
)

Every deleted key is a field the new model derives on load: the version number is the entry’s position, the hashes are computed from the decoded content, the physical descriptor comes from the field grammar, and a transition’s adjacency is implied by the version it sits on. Apply it:

jq -f convert-manifest.jq migrations/manifest.json > manifest.next &&
	mv manifest.next migrations/manifest.json

2. Convert the publication ledger

Save this as convert-publications.jq:

del(.formatVersion)
| .abiVersion = "0.20"
| .receipts |= map(
	del(.cluster)
	| .versions |= with_entries(.value |= {version: .version, history: []})
)
| if .pending then
	.pending.versions |= with_entries(.value |= {version: .version, history: []})
else
	.
end
jq -f convert-publications.jq migrations/publications.json > publications.next &&
	mv publications.next migrations/publications.json

Two things happen here, and both are deliberate. The cluster label is gone from receipts — the credential-free rpcUrl is the record now, and the pending record keeps its label because pina deploy matches on it. And each receipt’s history is emptied: the old pins hashed a document shape that no longer exists, so keeping them would fail every future check, while emptying them keeps what matters — the recorded version, which the next step pins again.

3. Pin the receipts

A current ledger must pin every version each receipt made live, so every command refuses the emptied histories until they are pinned again, naming the repair:

publication ledger 0.20 cannot be upgraded: receipt 0 names `account:1:01` without pinning its
published schemas. Confirm with version control that migrations/manifest.json still records exactly
what was deployed, then run `pina migrations reconcile --pin-legacy` to pin it

Once version control confirms that the manifest you converted in step 1 still describes exactly what those receipts made live, run:

pina migrations reconcile --pin-legacy

For each emptied entry it pins versions 0 through the recorded version from the manifest: the schema hash of each and, when it has one, the hash of the transition that enters it. That is trust on first use, which is why the confirmation comes first. The command writes the ledger in the current shape, so sequence, programId, manifestSha256, the previousReceiptSha256 chain link, and each contract’s version are gone and each contract becomes its list of pins. Nothing checks the old chain, so the edit in step 2 needs no chain repair.

4. Regenerate and verify

pina migrations create --no-interactive
pina migrations check
pina migrations status

create rewrites the manifest canonically and regenerates tests/abi_layout.rs. check must pass before anything builds. status is your proof the conversion preserved state: every contract shows the same version number it showed before the upgrade, and deployed contracts still read published. Confirm one live account still loads with pina migrations inspect <ADDRESS>, then commit the rewritten documents.

What pina_abi library consumers must change

If you import pina_abi directly — a custom indexer, a verification tool — the API moved from stored fields to derived values. The mapping:

RemovedReplacement
MANIFEST_FORMAT_VERSION, PUBLICATION_FORMAT_VERSIONABI_VERSION, ABI_VERSION_KEY, ABI_OLDEST_SUPPORTED
manifest.format_version: u32manifest.abi_version: String
SchemaVersion::versionthe entry’s index; ContractHistory::current_version(), .version(n)
SchemaVersion::schema_sha256method schema_sha256()
SchemaVersion::process_sha256method process_sha256()
DataSchema::physicalmethod physical() -> Result<PhysicalLayout, String>
Transition::from / ::toimplied: the transition on index i converts i - 1 into i
Transition neighbour hashes and process proofre-derive: ContractHistory::process_transition_into(n)
ProcessAccount::constraintsremoved — validation rules live in the IDL
PublicationReceipt::clusterremoved — receipts keep rpc_url; the pending record keeps cluster
encode_manifest_for_format, convert_manifest_format, convert_publication_ledger_formatdeleted — use encode_manifest, encode_publication_ledger

Added: walk_document and AbiStep for converter tables, parse_document_version and current_abi_version for comparisons, and document_schema / render_document_schema / schema_url behind the new pina abi schema command.

0.21 changed the API again. DataCodec and DataSchema::codec are removed: the wire codec is SCHEMA_CODEC, implied by abiVersion. ContractHistory::identity is no longer serialized and is filled from the contract key when a manifest is read (ContractIdentity::from_key parses one). ContractHistory gains envelope and is_migrated(). AbiStep carries one converter per document (manifest and publications), and walk_document takes an AbiDocument instead of a label.

The 0.21 ledger keeps only what nothing else records. PublicationReceipt and PendingPublication lose sequence, program_id, manifest_sha256, and previous_receipt_sha256, and PublicationReceipt::sha256 is removed. PublishedContract serializes as its list of pins: its version field and PublishedContract::legacy are gone, version() returns the position of the last pin, and pins(n) reports whether version n is pinned. pin_legacy_publications pins a 0.20 ledger’s unpinned entries from a manifest. wire_type and DataSchema::same_wire compare types by what they store, and MigrationAuto and MigrationVersionType implement FromStr for the --auto and --version-type spellings.

Troubleshooting

ErrorCause and remedy
missing a string \abiVersion` field`a pre-0.20 document — this guide
records ABI version 9.9, but this Pina build supports 0.21; upgrade Pinathe document is newer than your build — upgrade Pina; the check is a capability marker, like Cargo.lock
predates the oldest supported version 0.20; regenerate it with \pina migrations create``a document older than the reset — Start fresh
names ... without pinning its published schemasa converted ledger whose histories were emptied — Pin the receipts
`[migrations].auto` no longer belongs in pina.tomla retired pina.toml key — delete it; the manifest records the setting
pins ... for ... , which the manifest does not record or pinned schema ... but the manifest now recordsa receipt history that was not emptied — Keep your history

After the conversion, abiVersion is maintained for you: pina migrations create writes the current version, and the value advances only when a future pina_abi release changes the document contract — never for a CLI fix, never for a program schema change.

Migrate to automatic migrations

Automatic migrations let a program keep reading the bytes it already wrote. Instead of treating every schema change as a breaking deploy, Pina gives each opted-in account and event a framework-owned version field, snapshots the history into a checked-in manifest, and migrates accounts on demand when a stale one is touched. Instructions are recorded as snapshots that stop a wire-breaking change at build time, and gain a version field only when they opt in.

This guide upgrades a program to that workflow and opts it in wholesale with an auto policy, so new contracts are versioned without per-declaration annotations. It also lists the compatibility work that the change brings: the envelope is a wire-format change for accounts and events, and the generated clients change shape.

Before you start: this guide assumes the program is new, or that you are prepared to change the wire format of its accounts, instructions, and events deliberately. If the program is already live and its accounts were written without an envelope, read Adopting on a live program before you start.

What the envelope changes

Every enveloped contract gains one field immediately after its discriminator:

before: [discriminator][payload]
after:  [discriminator][schema version][payload]
Contract kindEnveloped whenWhat the version describes
Accountopted in by migrations or autoThe account’s stored layout
Instructiononly with the migrations token; auto records a snapshot onlyThe instruction payload plus its process account contract
Eventopted in by migrations or autoThe emitted log payload

An instruction covered by auto without the token keeps [discriminator][payload]. The manifest records one snapshot of it ("envelope": false), the build fails when the struct drifts from that snapshot, and after publication its payload is fixed: a new payload needs a new discriminator. How a migration flows covers the rules.

The version is little-endian, stored per contract, and hidden from generated accessors, patches, and instruction arguments. Each account, instruction, and event carries its own history starting at version 0, so a program with one account at version 3 and another still at 0 is normal. Only the width is program-wide: pina migrations create --version-type records it once for the program as versionType in the manifest: u8 (255 versions per contract, the default and the recommendation), u16, or u32. There is deliberately no u64; see Core Concepts.

Because the version is part of the bytes, changing it after publication is a breaking change for every migration-aware contract. That is why --version-type can change the width only while nothing is published, and why the manifest is checked in.

Step 1: Upgrade the dependency

[dependencies]
pina = { version = "0.17", default-features = false, features = ["derive"] }

Keep whatever feature set your program already uses. The breaking changes in this release are listed in What changes for clients and in the changelog.

A program that already has a migrations/manifest.json at abiVersion 0.20 recorded every instruction with an envelope. Read Upgrading from ABI 0.20 before running create, so a published instruction keeps its wire format.

Step 2: Choose the policy

The policy is a flag on the command that records the history in the next step:

pina migrations create --auto true # or --auto accounts,events,instructions, or a staged subset

--auto accepts true (or all) for every kind, false (or none) to clear the policy, or a comma-separated list of accounts, events, and instructions. Any other name, or a name listed twice, is rejected. Add --version-type u16 (or u32) to the same run only if one contract may need more than 255 versions.

Staging is a real option because the kinds cost different amounts. Covering instructions does not change their payload bytes: auto records each instruction as a snapshot without an envelope. An instruction you opt in with #[instruction(discriminator = X, migrations)] does gain the envelope, which changes its payload bytes and ripples into CPI call sites, so opt instructions in one at a time while your callers catch up.

The policy lives only in the manifest: the macros read the checked-in migrations/manifest.json, and pina.toml holds no copy, because a proc macro does not re-expand when an unrelated toml file changes and a second copy could disagree with the first. A program that still carries the retired [migrations].auto or [migrations].version_type keys fails every command that reads pina.toml until they are removed; the error names the create flag and value that record the same setting.

Step 3: Snapshot the history

pina migrations create --auto true

This records the policy in migrations/manifest.json, snapshots the current schema of every contract of the listed kinds, and generates an adjacent transition for each change it can prove structurally. Later runs keep the recorded policy, so they need no flag. Commit the manifest with your source: it is the checked-in source of truth the build consults.

A declaration the manifest does not record yet fails the build with the pina migrations create remedy, so you cannot forget a contract by accident.

Adding migrations to a program that is already published is a bulk wire-format change: for each newly enveloped contract, create records the envelope as a migration step rather than pretending the bytes already had one. A snapshot-only instruction adds no byte, so it does not count.

Step 4: Keep the build honest

When a policy is recorded, create scaffolds a build script so a policy flip re-expands every contract without a source edit:

fn main() {
	println!("cargo:rerun-if-changed=migrations/manifest.json");
}

The scaffold is idempotent and never overwrites a hand-written build script — when it cannot safely write one, it prints the exact line to add. Run pina migrations check in CI: it fails on drift, on incomplete manual transitions, and on a missing rerun directive.

Step 5: Answer what the diff cannot

Structural changes generate themselves (added, removed, reordered, or reseated fields). Semantic ones do not: a rename that changes meaning, a type narrowing, a split or merge of a field, or an authority change stops the build until you implement the transition and supply fixtures. That is the intended safety property — Pina will not guess what a value should become. A respelling that stores the same bytes, such as PodU64 as u64 or Address as [u8; 32], is not a change at all: it consumes no version and needs no transition.

Run pina migrations create after every schema change; when it reports an unresolved transition, implement it there. Fixed-account transitions are total and infallible once the exact source shape is validated; manual instruction transitions run in scratch space, so they can reject a value before anything is written. An instruction transition that adds an argument is always manual, because a zero-filled default is indistinguishable from a value the client sent. Events have no transitions: a changed event appends a version with its own schema.

Step 6: Know the bill before you ship

pina migrations status
pina migrations status --json

The status output now carries the cost preview: per contract, the current size, the pending growth for a day-one account, and the approximate rent deficit at the established ~6,960 lamports per grown byte; per instruction, the worst-case adjacent-step ladder a stale account can trigger with its static compute estimate; and a program-wide summary of the most expensive touching transaction. Sizing max_lamports and MAX_INLINE_STEPS stops being guesswork.

When a transition grows an account, create states the estimated deficit and names the on-chain error a too-small budget produces. Each budget failure is separately diagnosable on-chain, and each error’s rustdoc names its remedy:

ConditionErrorRemedy
Workspace smaller than WORKING_SIZEMigrationWorkspaceExceededSupply at least the generated WORKING_SIZE; it must stay within 1,024 bytes
A step grows past the runtime realloc limitMigrationAccountGrowthExceededPublish intermediate versions; no lamport budget can raise 10,240 bytes
Rent deficit above the lamport budgetMigrationLamportBudgetExceededRaise the program’s max_lamports constant, or pass a larger sweep budget
Ladder longer than the inline step limitMigrationUnavailableRebalance the history so no live account is more than MAX_INLINE_STEPS behind

Step 7: Regenerate the clients

Regenerate the IDL and every client after the program surface changes. pina generate refreshes the project IDL as part of the same run:

pina generate --client rust --client typescript --client dart
pina generate --client cpi
pina generate --client cli-rust

Generated clients write the current envelope version automatically, so they need no new arguments. Three client-side behaviors are new and worth reviewing in the diff:

  • Event decoders enforce the envelope. Each earlier version of an event is its own generated event, <Event>V<n>, with that version’s codec. The program-level log parser (parse<Program>EventsFromLogs in TypeScript and Dart) routes each record by discriminator and version and throws on a version no generated event describes; regenerate the client to decode it. Nothing is projected into the current shape. Prefer the generated log entry points over decoding event bytes by hand.
  • Account decoders and the read-only view. Account loaders still reject a foreign version with an actionable error. When a program only ever reads a migratable account, try_from_bytes_versioned returns a Current or historical view without migrating; you then handle each representation your history exposes.
  • Migration-aware helpers. Generated clients carry the per-account needsMigration checks and the reserved Migrate composer, so a client can send the sweep before the real instruction described in How a migration flows.

Step 8: Roll out

  1. Deploy the program. A stale enveloped account is migrated by whichever instruction touches it writably; a read-only touch fails with MigrationRequired until one writable touch happens.
  2. For read-heavy accounts, send the sweep instruction first — it migrates every listed account that is behind and is cheap to send speculatively. The full pattern, including the payer rules, is in How a migration flows.
  3. Ship the regenerated clients. A client generated from an older release keeps working: it writes its own frozen version, the program migrates the accounts it touches, and an instruction that opts into migrations has its payload normalized before the handler runs.

Adopting on a live program

This is the case the automated flow deliberately does not decide for you.

Pina refuses to interpret unversioned bytes as version zero, because the first bytes of an old payload could look like a valid version by accident. So a program whose accounts were written before the envelope existed cannot simply switch auto on: the existing accounts have no version field, and no generated transition can read a layout nobody recorded. Instructions do not have this problem: auto records them as snapshots without an envelope, which match the bytes live clients already send.

You have two honest options:

  1. A new discriminator. Ship the versioned layout under a fresh account or instruction discriminator. Nothing is silently reinterpreted, and both layouts can coexist while you move accounts over.
  2. A deliberate legacy bridge. Record the unversioned layout as the baseline and write the transition into the enveloped shape by hand, then migrate accounts through a writable path you control.

The framework ergonomics for the second option are specified in ADR 0008 and are not built yet. Until they are, treat adoption on a live program as a migration project rather than a configuration flip — and confirm the exact account bytes you expect with pina migrations status before enabling the policy in production.

What changes for clients

  • The wire format of every enveloped contract gains the version field. Anything that parses raw account data, event records, or the payload of an instruction that opts into migrations without going through the generated client must be updated. A snapshot-only instruction’s payload is unchanged.
  • Generated event decoders changed shape: decoded events include the envelope’s discriminator and migration version, and each historical version is its own <Event>V<n> event decoded with its own schema. Decoded records carry no source-version or was-migrated flag, because nothing is converted.
  • The on-chain budget error was split into the three distinguishable codes above. The legacy aggregate code keeps its original value so binaries compiled before the split stay decodable, but client error mappings should learn the new codes.

Verify the result

  • pina migrations check in CI, to fail on drift or a missing rerun directive.
  • pina test --compatibility to exercise every historical version of every contract against checked-in fixtures.
  • pina migrations status to review the cost picture before deploying.

Once those pass and the manifest is committed, the next schema change flows through create instead of through a breaking deploy.

Migrate Pina to PinaPod v0.2

This guide covers the coordinated PinaPod v0.2 and Pina migration. PinaPod v0.2 breaks Rust source compatibility, but preserves existing fixed and compact wire bytes. Both repositories are maintained together, so the migration removes the v0.1 names instead of carrying aliases.

Merge and release in dependency order

  1. Merge the PinaPod v0.2 pull request after its native, Miri, compile-fail, and benchmark checks pass.
  2. Publish PinaPod v0.2 and its mdBook documentation.
  3. Replace Pina’s temporary path or Git dependency with the published v0.2 version.
  4. Update Cargo.lock and run Pina’s locked test matrix.
  5. Regenerate Pina’s IDLs and Rust, TypeScript, and Dart clients.
  6. Merge the Pina pull request after its generated-artifact and SBF checks pass.

Do not merge Pina first. Published Pina crates must not contain a path dependency, and Pina’s CI rejects a manifest-only dependency update because it runs Cargo with --locked.

Update public names

Replace the v0.1 names as one change:

PinaPod v0.1PinaPod v0.2
ZeroPodPinaPod
ZeroPodFixedPinaPodFixed
ZeroPodCompactPinaPodCompact
ZeroPodErrorPinaPodError
ZeroPodSchemaPinaPod
from_bytesread_exact or read_prefix
from_bytes_mutread_exact_mut or read_prefix_mut
validate on a fixed schemavalidate_exact or validate_prefix

Pina re-exports the new names from crates/pina/src/lib.rs. Pina’s #[account], #[instruction], and #[event] macros inject #[derive(PinaPod)], so application schemas normally do not name the derive.

Manual PinaPodFixed implementations are unsafe. The implementation must prove the layout, alignment, initialization, and validation invariants documented by PinaPod. Prefer Pina’s schema macros.

Use fixed strings, vectors, and options directly

Fixed Pina schemas now accept bounded collections and recursively fixed options:

#![allow(unused)]
fn main() {
#[account(discriminator = AccountType)]
pub struct Profile {
	pub authority: Address,
	pub display_name: String<32>,
	pub scores: Vec<u64, 8>,
	pub delegate: Option<Address>,
	pub note: Option<String<64>>,
	pub history: Vec<Option<u16>, 16>,
}
}

The fixed layout reserves every field’s full capacity. For example, String<32> occupies its length prefix plus 32 data bytes even when it is empty. PinaPod zeroes inactive capacity during initialization and after values become shorter. It also validates every active nested value before returning a safe view.

String<N> and Vec<T, N> are PinaPod schema aliases. Their source is visible to Rust tooling, but the short names keep declarations readable. Use the Pod* forms to select a prefix width:

#![allow(unused)]
fn main() {
#[account(discriminator = AccountType)]
pub struct Archive {
	pub title: PodString<1024, 2>,
	pub entries: PodVec<u64, 1024, 2>,
}
}

The final const argument is the prefix width in bytes. It must be 1, 2, 4, or 8. Do not use #[pinapod(prefix = u16)]; v0.2 does not use an attribute to configure container prefixes.

Move the fixed collection fixtures from tests/ui/fail to tests/ui/pass. Keep runtime tests for empty, full, and over-capacity values, invalid UTF-8, invalid option tags, invalid nested elements, clearing, and replacement.

Initialize fixed accounts in one pass

Typed fixed-account creation now has two initialization paths:

  • invoke::<T>() and invoke_signed::<T>(signers) write the discriminator and leave every other byte at zero. Use them only when that completed representation is valid.
  • invoke_with::<T>(initialize) and invoke_signed_with::<T>(signers, initialize) run a caller-supplied initializer before PinaPod validates the completed representation. Use them when the account needs nonzero initial values.
  • invoke_with_bump::<T>(initialize) and invoke_signed_with_bump::<T>(signers, initialize) also pass the canonical bump into the initializer so the account can store it without a second derivation.

Move a create-then-mutate sequence into the creation call:

#![allow(unused)]
fn main() {
CreateProgramAccountWithBump {
	account: self.profile,
	payer: self.authority,
	owner: &ID,
	seeds: &seeds.as_slices(),
	bump,
}
.invoke_with::<Profile>(|profile| {
	profile.authority = *self.authority.address();
	profile.name.try_set("Alice")?;
	profile.scores.try_set([10, 20, 30])?;
	Ok(())
})?;
}

The closure receives &mut T::Zc and returns Result<(), PinaPodError>. PinaPod zeros the complete account representation, Pina writes the discriminator, the closure configures the remaining fields, and PinaPod validates once at the end. If the closure or validation fails, PinaPod zeros the account bytes again and the create instruction returns InvalidAccountData.

This distinction matters for advanced manual PinaAccount implementations. A storage enum such as Ready = 1 has no valid all-zero discriminant, so invoke::<T>() fails validation. Set the field inside invoke_with instead:

#![allow(unused)]
fn main() {
.invoke_with::<RequiredState>(|state| {
	state.mode = RequiredMode::Ready.into();
	Ok(())
})?;
}

Pina’s audited #[account] grammar still rejects arbitrary custom enum fields. The enum example applies to a direct PinaPod schema with a manual PinaAccount implementation. Use invoke_with for ordinary macro-generated accounts too when atomic initialization is clearer than borrowing and mutating the account after creation.

When the payer needs additional PDA signatures, pass them before the initializer:

#![allow(unused)]
fn main() {
builder.invoke_signed_with::<Profile>(&payer_signers, |profile| {
	profile.authority = authority;
	Ok(())
})?;
}

Load fixed PDA accounts in one pass

Do not validate a fixed PDA with assert_type, load it again through the generated assert_seeds, and then load it a third time for mutation. Bounded strings and nested containers make each recursive validation meaningful, so repeating the boundary also repeats its compute cost.

Use the one-pass helpers generated for fixed #[account] plus #[pda(bump = ...)] schemas:

#![allow(unused)]
fn main() {
let mut profile = Profile::load_pda_mut(
	self.profile,
	self.authority.address(),
	&ID,
)?;
profile.name.try_set("Alice")?;
}

load_pda_mut checks writability, owner, exact size, discriminator, every active PinaPod value, and the account address derived from the stored bump before it returns the mutable guard. load_pda provides the same one-pass contract for immutable access. Both guards retain the runtime data borrow, so drop them before a CPI that can access the account.

Keep Type::assert_seeds for a validation-only path that does not need a typed guard. The one-pass loaders are the preferred path when code reads or writes the account immediately afterward.

Apply the same rule to non-PDA fixed accounts: replace assert_type::<T>() followed by as_account::<T>() or as_account_mut::<T>() with the typed loader alone. Both loaders perform the owner, discriminator, exact-size, and nested-value validation before returning their borrow guard. Keep assert_type only when validation is the final operation on typed data, such as a close path that never reads the fields. It releases the borrow before returning and must not be treated as proof for a later raw cast.

Typed fixed-account boundaries now return PinaProgramError::InvalidAccountSize when the account is undersized or oversized. This applies to assert_type, as_account, as_account_mut, load_pda, and load_pda_mut. Update any precise error matching that previously expected ProgramError::AccountDataTooSmall or ProgramError::InvalidAccountData for these size failures. The generic assert_data_len check retains its existing ProgramError::InvalidAccountData contract.

Load compact PDA accounts in one borrow

Do not validate a compact PDA with assert_compact_type, load its bump through generated assert_seeds, and then parse it again with with_compact_account.

Use Type::with_pda for a compact #[account] with #[pda(bump = ...)]:

#![allow(unused)]
fn main() {
let (revision, entries) = Journal::with_pda(
	self.journal,
	self.authority.address(),
	&ID,
	|journal| Ok((journal.revision.get(), journal.entries().len())),
)?;
}

with_pda validates ownership, compact data, and the address the stored bump derives before it runs the closure, using a single derivation. with_checked_pda searches for the canonical bump instead and rejects an account at any other address or with a noncanonical stored bump. The closure cannot return the borrowed compact view. Use assert_compact_type or generated assert_seeds only when code needs validation without field access.

Keep compact nesting inside the supported grammar

A compact account places fixed fields first and compact tails last. It can contain several tails. PinaPod v0.2 accepts these compact forms:

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

Option<T> for fixed T remains an inline header field. The other forms use compact tail storage. Vec<String<M>, N> stores fixed-footprint string elements, so each active element occupies its own prefix plus M bytes. The strings can have different logical lengths.

#![allow(unused)]
fn main() {
#[account(discriminator = AccountType, compact)]
pub struct Journal {
	pub authority: Address,
	pub revision: u32,
	pub archived_at: Option<u64>,
	pub title: String<64>,
	pub entries: PodVec<u64, 1024, 2>,
	pub note: Option<String<128>>,
	pub labels: Vec<String<24>, 16>,
}
}

Pina’s schema classifier is closed. It rejects unsupported nesting instead of accepting an arbitrary ZcField implementation. The diagnostic lists the supported forms.

Compact creation also requires an initial patch. Pass it to invoke, or construct it from the canonical bump with invoke_with_bump:

#![allow(unused)]
fn main() {
CreateCompactProgramAccount {
	account: self.journal,
	payer: self.authority,
	owner: &ID,
	seeds: &Journal::seeds(self.authority.address()).as_slices(),
	space: Journal::MIN_SIZE,
}
.invoke_with_bump::<Journal, _>(|bump| {
	JournalPatch::new()
		.bump(bump)
		.authority(*self.authority.address())
		.revision(0)
})?;
}

The builder applies patch while it initializes the allocated bytes. Omitted patch fields use their zero, empty, or absent representation. Include nonempty tail replacements in the patch and allocate enough space for those encoded values.

Replace staged compact mutation with a patch

PinaPod v0.1 exposed a mutable view whose setters accumulated changes before commit(). The caller also had to order ReallocCompactAccount differently for growth and shrink. Remove that lifecycle.

Build a generated patch and pass it to Pina’s typed update operation:

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

JournalPatch distinguishes an unchanged field from a field set to an empty or absent value. A method named after a fixed field replaces that field. A replace_* method replaces a collection tail. The patch validates every requested value and computes the target length before Pina changes account bytes or lamports.

The typed creation and update builders accept the generated patch associated with the account. They no longer accept an unrelated custom PinaPodPatch<T> implementation, because that could bypass the account’s generated structural and application validation. Existing calls that pass JournalPatch are unchanged. For a generic helper, replace a PinaPodPatch<T> bound with PinaCompactPatch<T>. A wrapper may implement PinaCompactPatch<T> by returning the generated patch it contains:

#![allow(unused)]
fn main() {
struct JournalUpdate<'a> {
	patch: JournalPatch<'a>,
}

impl PinaCompactPatch<Journal> for JournalUpdate<'_> {
	fn as_pina_patch(&self) -> &JournalPatch<'_> {
		&self.patch
	}
}
}

UpdateResizableAccount ends the data borrow before resizing. It grows the account before writing, or writes a structurally valid shorter representation before shrinking. It clears bytes removed by the update. If structural preflight fails, account bytes and lamport balances remain unchanged. With the validation feature, application rules run after the patch is written; propagate the returned error so Solana rolls back the bytes and any earlier rent movement.

Use rent_account for this builder. The account both funds growth and receives excess rent after a shrink. The lower-level ReallocAccount, ReallocAccountZeroed, and ReallocCompactAccount builders use the same field name and take an explicit target_size. Creation and allocation builders retain payer because those APIs only fund a new account.

Keep wire bytes stable

The following source changes do not change existing serialized data:

  • Renaming the derive and traits changes Rust names only.
  • Replacing fixed read method names changes validation entry points only.
  • Replacing create-then-mutate code with invoke_with changes initialization order, not the completed fixed-account bytes.
  • Replacing assert_type plus assert_seeds plus as_account* with load_pda* changes validation order, not account bytes.
  • Replacing assert_compact_type plus assert_seeds plus with_compact_account with with_pda changes validation order, not account bytes.
  • Replacing staged compact mutation with a patch changes how callers produce the same compact bytes.
  • Adding client capacity metadata changes validation, not the encoded prefix or payload.

The following schema changes do change layout and require the normal on-chain migration process:

  • Adding a new field to an existing account.
  • Changing a field’s capacity or prefix width.
  • Moving a field or changing its fixed versus compact classification.
  • Changing a discriminator value or width.

PinaPod pins byte-for-byte v0.1 fixtures. Pina also keeps account, instruction, IDL, and generated-client fixtures so both sides detect an accidental layout change.

Regenerate clients and enforce capacity

Run Pina’s normal IDL and client generation commands after the Rust schemas compile. Compact capacity must be structured schema data, not a number recovered from field documentation. Generated TypeScript and Dart clients enforce each capacity at both boundaries:

  • Encoders reject capacity + 1 before writing a prefix.
  • Decoders reject an oversized prefix before allocating or iterating.
  • Multi-tail accounts check each field against its own capacity.
  • Decoders keep discriminator checks and consume the exact encoded length.

Do not edit files under codama/clients/*/generated by hand.

Verify the migration

Run these commands from the Pina repository:

devenv shell docs:sync
devenv shell verify:docs
devenv shell cargo test -p pina_root --test ui --locked
devenv shell cargo test -p pina --all-features --locked
devenv shell cargo test -p pina_cli --all-features --locked
devenv shell cargo test -p pina_codama_renderer --locked
devenv shell build:pina:no-default
devenv shell test:idl
devenv shell -- report:cu:compare:main

Then run the generated TypeScript and Dart contract suites, the tracked SBF compact-account build, and the Surfpool lifecycle tests. The failure cases must compare both account data and lamport balances before and after the rejected update.

The compute-unit report must show savings as positive values and increases as negative values. Exact runtime increases fail unless scripts/compute-unit-policy.json records a reviewed exception matching the PR base commit and measured baseline. See Compute-unit performance for the PinaPod v0.2 measurements and methodology.

Migrate lamport mutation calls

Pina now checks program ownership inside every public helper that directly debits lamports. This release changes Rust source APIs but does not change instruction or account wire formats.

Replace send with send_owned

Remove the separate assert_owner call and pass the executing program ID to send_owned:

#![allow(unused)]
fn main() {
// Before
vault.assert_owner(&ID)?;
vault.send(amount, recipient)?;

// After
vault.send_owned(&ID, amount, recipient)?;
}

send_owned rejects a sender that ID does not own before it changes either balance. It also retains the existing writable, self-transfer, insufficient-funds, and overflow checks.

Pass the program ID when closing an account

Pass the executing program ID to both close methods:

#![allow(unused)]
fn main() {
// Before
account.close_with_recipient(recipient)?;
account.close_account_zeroed(recipient)?;

// After
account.close_with_recipient(&ID, recipient)?;
account.close_account_zeroed(&ID, recipient)?;
}

Add program_id to the close builders:

#![allow(unused)]
fn main() {
CloseAccountZeroed {
	account,
	recipient,
	program_id: &ID,
}
.invoke()?;
}

Both builders validate ownership before changing account data or lamports. CloseAccountZeroed still clears the existing data bytes before closing. CloseAccount still leaves the old backing bytes unchanged.

Remove the old lint configuration

Delete require_program_owned_before_lamport_mutation from pina.toml if the project lists it explicitly. Pina removed the lint because send_owned now performs the check itself. Other close and lamport safety lints remain available.

Verify the migration

Run the program tests and Pina lints after updating every call:

cargo test
pina lint

Migrate to safe token loaders

Pina now exposes one clearly checked name for each token loader. The previous unsuffixed loaders were already owner-safe because the pinned pinocchio-token and pinocchio-token-2022 account-view parsers validate ownership and layout together. Pina continues to delegate to those parsers without a duplicate owner comparison. The ATA loader additionally validates the derived address and the current authority and mint stored in token-account data. This change removes the redundant checked method names and the lints that required preceding assertions.

Remove checked suffixes

Replace each removed method with its shorter equivalent:

BeforeAfter
as_token_mint_checked()as_token_mint()
as_token_account_checked()as_token_account()
as_token_2022_mint_checked()as_token_2022_mint()
as_token_2022_account_checked()as_token_2022_account()
as_associated_token_account_checked(...)as_associated_token_account(...)

The replacement methods enforce the same owner checks through the same checked upstream parsers. They preserve guard-backed lifetimes and return the same typed views as the removed methods.

Remove duplicate assertions

Delete owner and ATA assertions that exist only to guard an immediate loader call:

#![allow(unused)]
fn main() {
// Before
account.assert_owner(&token::ID)?;
let account = account.as_token_account()?;

vault.assert_associated_token_address(wallet, mint, token_program)?;
let vault = vault.as_associated_token_account(wallet, mint, token_program)?;

// After
let account = account.as_token_account()?;
let vault = vault.as_associated_token_account(wallet, mint, token_program)?;
}

Keep assert_owner(), assert_owners(), and assert_associated_token_address() in validation-only paths that do not read token data and do not reach an ATA instruction.

Delete an ATA address assertion that is immediately followed by associated_token_account::instructions::Create or CreateIdempotent on the same account. The associated token program derives the same [wallet, token_program, mint] seeds under its own program id and returns InvalidSeeds when they do not produce the named account, so the assertion repeats a canonical bump search worth roughly 4,500 compute units. CreateIdempotent runs that check before its idempotent branch, so it applies to existing accounts too.

#![allow(unused)]
fn main() {
// Before
vault.assert_empty()?.assert_writable()?;
vault.assert_associated_token_address(wallet, mint, token_program)?;
associated_token_account::instructions::Create { account: vault, /* ... */ }.invoke()?;

// After
vault.assert_empty()?.assert_writable()?;
associated_token_account::instructions::Create { account: vault, /* ... */ }.invoke()?;
}

Keep assert_empty() and assert_writable(): they enforce initialization state and runtime permissions, which no CPI restates.

Pick the loader by program policy

Use the unqualified legacy loaders when the instruction requires the original SPL Token program:

#![allow(unused)]
fn main() {
let mint = mint_account.as_token_mint()?;
let token_account = token_account.as_token_account()?;
}

Use the explicit Token-2022 loaders when the instruction requires Token-2022:

#![allow(unused)]
fn main() {
let mint = mint_account.as_token_2022_mint()?;
let token_account = token_account.as_token_2022_account()?;
}

Use *_for_program() when the instruction accepts either canonical token program:

#![allow(unused)]
fn main() {
token_program.assert_addresses(&[token::ID, token_2022::ID])?;
let program_id = *token_program.address();
let mint = mint_account
	.as_token_mint_for_program(&program_id)?
	.assert_no_extensions()?;
let account = token_account.as_token_account_for_program(&program_id)?;
}

The dynamic loaders reject unsupported program IDs and require each account’s runtime owner to equal program_id.

Update lint configuration

Remove these retired lint names from [lints] in pina.toml:

require_owner_before_token_cast = "deny"
require_associated_token_address_before_ata_cast = "deny"

Pina no longer needs lexical checks for these conditions because the loader boundary enforces them at runtime. require_consistent_token_program and require_explicit_token_2022_extension_policy remain active.

Check ATA assumptions

as_associated_token_account() is a canonical ATA loader. It rejects the address before parsing when it is not the ATA derived from wallet, mint, and token_program. It then rejects a token account when its stored current authority or mint differs from those inputs. A token account whose authority was reassigned remains at its original associated address, but it is no longer canonical for the original wallet under this loader. If a program intentionally accepts that state, load it with as_token_account_for_program() and validate the program-specific relationship explicitly.

The loader does not require the account to be initialized or unfrozen, and it does not restrict delegates, close authority, or Token-2022 extensions. Apply those protocol-specific checks after loading. For Token-2022, use assert_extensions_allowed() or assert_no_extensions() where the protocol has an extension policy.

Check error-code expectations

The checked upstream parsers preserve their own precise errors. In particular, the legacy and Token-2022 token-account parsers currently report a wrong owner as InvalidAccountData, while their mint parsers and Token-2022 extension-aware wrapper report InvalidAccountOwner. Code should normally propagate these errors rather than branching on them. Tests that asserted an exact error from a removed *_checked wrapper may need to be updated.

Migrate to hardened security lints

This release adds a deny-by-default check for unchecked mutable remaining accounts and makes existing lint analysis follow more source-level control flow. A project that previously passed pina lint can therefore fail until it makes duplicate-account and compute bounds explicit.

Reject duplicate mutable accounts by default

Replace direct remaining_mut() calls with the distinct loader:

#![allow(unused)]
fn main() {
let remaining = cursor.remaining_mut_distinct()?;
}

The lint covers method syntax, UFCS, stored function items, and calls produced by local or dependency macros. If duplicate mutable addresses are part of the protocol, prefer the typed escape hatch and explain the invariant where reviewers can see it:

#![allow(unused)]
fn main() {
#[derive(Accounts)]
struct WeightedAccounts<'a> {
	/// Duplicate entries intentionally apply the same account's weight more than once.
	#[pina(remaining, distinct = false)]
	positions: &'a mut [AccountView],
}
}

For a manual parser, contain remaining_mut() in the smallest reviewed helper and add a local lint allowance with a specific safety comment. Do not disable the lint for the crate.

Re-establish bounds after mutation

The remaining-account bound check now follows local aliases and loop-carried state. Reassignment, mutable borrowing, a mutable method receiver, a mutable-reference function argument, or a closure that can replace a checked binding invalidates the earlier proof, including for later iterations of an enclosing loop. Move the guard after the last possible mutation:

#![allow(unused)]
fn main() {
replace_remaining(&mut remaining, replacement);

if remaining.len() > MAX_REMAINING_ACCOUNTS {
	return Err(ProgramError::InvalidArgument);
}

for account in remaining {
	process(account)?;
}
}

A constant .take(MAX) is also accepted, including when the bounded iterator is stored in a local variable. Use it only when silently ignoring surplus accounts is part of the instruction contract:

#![allow(unused)]
fn main() {
let bounded = remaining.iter().take(MAX_REMAINING_ACCOUNTS);
for account in bounded {
	process(account)?;
}
}

Adapters that cannot increase the number of items, such as filter, map, and enumerate, preserve the bound. Apply take after flat_map, flatten, cycle, or another adapter that can expand its input.

Every remaining-account source in a chained iterator needs its own dominating length check. The lint only accepts the built-in length of a slice or array as evidence; a custom method named len cannot establish a security bound.

Review unused borrow-guard warnings

The unused-borrow-guard lint now classifies each binding’s inferred type instead of matching loader names inside an expression. It therefore catches Ref and RefMut results returned through aliases or function pointers, including guards nested in tuple and let ... else patterns, while no longer treating an unrelated wrapper result as a guard merely because the wrapper consumed one. If code intentionally stores a guard, read through the binding; otherwise discard the loader result immediately with ? so the runtime borrow ends at the statement boundary. Write let _ = account.try_borrow()?; at the creation site when an explicit discard reads better. Do not write let _ = guard; after binding a guard: Rust’s wildcard pattern does not move that local, so the borrow remains live and the lint continues to warn.

Refresh the managed lint driver

No manual cache cleanup is required because there is no cache anymore. The lint driver ships prebuilt next to the pina CLI in every release archive and npm platform package, built by the release pipeline from the pinned nightly in rust-toolchain.toml — the same release pina init scaffolds. The CLI resolves the driver beside its own executable and starts it once before the lint run to confirm it loads; a CLI installed with cargo install pina_cli does not bundle a driver, so install the prebuilt CLI or set PINA_LINT_DRIVER_PATH for local lint development.

The lint run also prepends the active toolchain’s sysroot library directory to the dynamic-library search path (DYLD_LIBRARY_PATH and LD_LIBRARY_PATH on macOS, LD_LIBRARY_PATH on other Unix). Because the compiler internals in librustc_driver are keyed to the exact nightly build, the project’s active toolchain must be that pinned nightly; a mismatch fails the load check with an error naming the required toolchain instead of failing deep inside cargo with a loader exit. If PINA_LINT_DRIVER_PATH is set, make sure it points to a driver built by the same toolchain as the project.

Run the complete catalog after migrating:

pina lint

Review every lint-level override in pina.toml. Temporary allow entries can unblock an incremental migration, but restore deny-level security checks before deployment.

Migrate to internal creation emptiness checks

Pina’s typed creation builders now enforce emptiness themselves and return AccountAlreadyInitialized when the target storage holds any nonzero byte. The deny-by-default require_empty_before_init lint is retired.

Remove the lint from pina.toml

Delete the [lints] entry; the CLI now rejects the unknown name.

[lints]
# remove:
# require_empty_before_init = "deny"

Remove manual pre-creation assertions

Delete assert_empty() calls that exist only immediately before a typed creation builder. Keep assert_empty() calls that guard other operations, such as raw CreateAccount allocations or token-account creation, where the typed builders are not involved.

#![allow(unused)]
fn main() {
// before
self.state.assert_empty()?;
CreateProgramAccountWithBump { account: self.state, /* .. */ }.invoke_with::<State>(/* .. */)?;

// after
CreateProgramAccountWithBump { account: self.state, /* .. */ }.invoke_with::<State>(/* .. */)?;
}

Error behavior is preserved: the builders return the same AccountAlreadyInitialized error the manual assertions produced.

Migrate to canonical PDA builders

Pina’s typed PDA creation builders now own canonical bump validation. Remove assert_canonical_bump and assert_seeds_with_bump when they occur immediately before CreateProgramAccount, CreateProgramAccountWithBump, CreateCompactProgramAccount, or CreateCompactProgramAccountWithBump. All four validate the PDA: the explicit-bump builders reject a noncanonical supplied bump, and the bump-free builders derive and sign with the canonical one.

Keep assert_empty before creation. It preserves the distinct AccountAlreadyInitialized error and remains part of Pina’s initialization lint contract.

Migrate a supplied bump

Previously, a handler derived the PDA three times: once to find the canonical bump, once to validate the explicit bump, and once inside the creation builder.

#![allow(unused)]
fn main() {
let canonical_bump = account.assert_canonical_bump(&seeds.as_slices(), &ID)?;
if canonical_bump != args.bump {
	return Err(ProgramError::InvalidSeeds);
}

let seeds_with_bump = seeds.with_bump(args.bump);
account.assert_empty()?;
account.assert_seeds_with_bump(&seeds_with_bump.as_slices(), &ID)?;

CreateProgramAccountWithBump {
	account,
	payer,
	owner: &ID,
	seeds: &seeds.as_slices(),
	bump: args.bump,
}
.invoke_with::<State>(|state| {
	state.bump = args.bump;
	Ok(())
})?;
}

Now the builder performs one canonical search, verifies both the account address and supplied bump, and reuses that result for the PDA signer:

#![allow(unused)]
fn main() {
account.assert_empty()?;

CreateProgramAccountWithBump {
	account,
	payer,
	owner: &ID,
	seeds: &seeds.as_slices(),
	bump: args.bump,
}
.invoke_with::<State>(|state| {
	state.bump = args.bump;
	Ok(())
})?;
}

The explicit-bump builder now rejects a valid but noncanonical PDA with ProgramError::InvalidSeeds. This is an intentional security change.

Derive and store the bump

If the instruction does not need to carry a bump, let the canonical builder derive it and pass it into the initializer:

#![allow(unused)]
fn main() {
let (_address, bump) = CreateProgramAccount {
	account,
	payer,
	owner: &ID,
	seeds: &seeds.as_slices(),
}
.invoke_with_bump::<State>(|state, bump| {
	state.bump = bump;
	Ok(())
})?;
}

invoke_signed_with_bump provides the same initializer and also accepts signer seeds for a PDA payer.

Migrate compact creation

CreateCompactProgramAccount no longer stores a patch field. Pass an ordinary patch to invoke, or construct it from the derived bump with invoke_with_bump:

#![allow(unused)]
fn main() {
CreateCompactProgramAccount {
	account: journal,
	payer,
	owner: &ID,
	seeds: &Journal::seeds(authority).as_slices(),
	space: Journal::MIN_SIZE,
}
.invoke_with_bump::<Journal, _>(|bump| {
	JournalPatch::new().authority(*authority).bump(bump)
})?;
}

Code that already has a patch but does not store a bump uses .invoke::<Journal>(patch). Both signed forms take the additional signer slice before the patch or bump-aware patch factory.

CreateCompactProgramAccountWithBump keeps its patch field, but now rejects a noncanonical supplied bump before allocation.

Keep generated-client PDA resolution

The IDL extractor recognizes all four typed creation builders as canonical PDA checks:

  • CreateProgramAccount
  • CreateProgramAccountWithBump
  • CreateCompactProgramAccount
  • CreateCompactProgramAccountWithBump

Removing the redundant assertions does not make the target account required in generated clients. JavaScript callers can keep using the asynchronous builder and omit the PDA account:

const instruction = await getInitializeInstructionAsync({
	authority,
	bump,
});

The generated client derives the account from the PDA metadata in the IDL. You can still pass the account explicitly when needed.

Use noncanonical allocation only for compatibility

AllocateAccountWithBump is deprecated. Its replacement is AllocateAccountWithNonCanonicalBump, which makes the relaxed guarantee clear in code review. It accepts any valid PDA bump and only allocates untyped bytes. Use it for an existing address scheme that cannot migrate to canonical bumps. Use AllocateAccount for new PDA namespaces.

The noncanonical allocation and unchecked typed-creation builders now hash the supplied seeds locally and let the runtime perform the curve check during CPI signer derivation. An on-curve bump therefore aborts execution rather than returning a catchable Err(ProgramError::InvalidSeeds) from the builder. If an instruction catches invalid bumps and continues, call create_program_address with the full seed list, including the bump, before invoking the builder and handle its error there. This optional preflight restores recovery while keeping the default creation path cheap. Canonical builders already prove a valid bump before allocating.

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

ADRStatusDecision
ADR 0001AcceptedKeep discriminator bytes as the first field inside typed layouts.
ADR 0002AcceptedKeep zero-copy for fixed-size Pod layouts, but only behind explicit validation.
ADR 0003AcceptedKeep runtime borrow guards alive for the full typed loader lifetime.
ADR 0004AcceptedPreserve no_std / no-allocator constraints for on-chain code paths.
ADR 0005AcceptedKeep SPL token support optional and feature-gated.
ADR 0006AcceptedTreat CI as layered verification, not a single all-purpose test lane.
ADR 0007ProposedMake migrations a generated, on-demand, versioned ABI compatibility boundary.
ADR 0008ProposedReserve a migration instruction, add client migrate-first flow, and support adopting migrations on already-launched programs.
ADR 0009AcceptedGive pina_abi its own release line, pin a committed abiVersion to it, reset the document to stored facts, and publish generated JSON Schemas.
ADR 0010AcceptedBound the entrypoint account array per program and keep pinocchio as a library; ADR 0011 proposes revisiting its rejection of a dispatcher.
ADR 0011ProposedDispatch on the SIMD-0321 instruction-data pointer before reading accounts, parse only the routed instruction’s accounts, and re-verify stored-bump PDAs with sha256.
ADR 0012AcceptedJudge instruction account slots on wire facts only; defaultValue and pda client hints never block a version.

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_std and 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
  • why the ABI document’s abiVersion is a committed value pinned to pina_abi’s own release line instead of counting format revisions
  • why a changed PDA or known-address hint on an instruction account never blocks a published version

ADR 0001: Keep discriminator-first typed layouts

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, amended for PinaPod v0.2
  • Date: 2026-04-18
  • Last amended: 2026-09-07
  • Deciders: Pina maintainers
  • Related: Security model, PinaPod v0.2 migration, security/loaders-audit.md

Context

Zero-copy account access reduces compute and memory use, but only when the representation contract is closed. A reference formed from unchecked account bytes can cause undefined behavior before later semantic validation runs.

The first version of this decision limited application schemas to scalar fields and fixed byte arrays. PinaPod v0.2 now fully initializes bounded containers, recursively validates active values, and limits compact mutation to generated patches. Those guarantees allow Pina to accept bounded strings, vectors, and options without weakening the boundary.

Decision

Pina keeps zero-copy account and instruction handling as a core design choice. PinaPod owns representation and byte-to-view conversion. Pina’s macros enforce a closed field grammar before invoking the derive.

The fixed schema grammar accepts audited scalars, addresses, byte arrays, String<N>, Vec<T, N>, and Option<T> where every nested T has a fixed PinaPod representation. PodString<N, PFX> and PodVec<T, N, PFX> select an explicit 1, 2, 4, or 8 byte prefix.

Compact accounts use a narrower grammar because each dynamic field needs generated offset and patch logic. They accept:

  • String<N>
  • Vec<T, N> for fixed T
  • Option<T> for fixed T
  • Option<String<N>>
  • Option<Vec<T, N>> for fixed T
  • Vec<String<M>, N>

Dynamic fields form the final suffix, and one account can have several tails. Unsupported nesting fails at macro expansion with an error that lists the accepted forms.

The boundary also requires these rules:

  • Typed loads validate the discriminator, size, content, and relevant account identity before use.
  • PinaPod initializes inactive fixed-container capacity and zeroes payload bytes removed by safe mutations.
  • Generated compact patches validate the complete update before changing account data or lamports.
  • Pina does not duplicate PinaPod pointer casts or expose a schema or storage-view object representation as bytes.
  • A manual PinaPodFixed implementation is an unsafe escape hatch whose author owns every documented invariant.

Consequences

Fixed accounts can store bounded text, lists, and options without custom byte-array helpers. They pay rent for the declared capacity because the full representation is inline.

Compact accounts pay only for active tail data. Their API is more constrained: reads use generated views, and writes use a generated patch plus UpdateResizableAccount. The framework owns grow-before-write and write-before-shrink ordering.

Pina remains narrower than Rust’s type system and the complete Codama schema language. Custom mappings and unsupported dynamic nesting fail at compile time rather than falling back to unchecked behavior.

Unbounded dynamic data models must use explicit versioning or companion accounts. Loader APIs keep runtime borrow guards because a plain &T cannot represent the account-data borrow lifetime. Future field forms must prove their layout and aliasing safety before Pina adds them to the macro grammar.

Historical comparison

The original ADR compared Pina with Quasar and kept collections outside Pina’s schema macros. That restriction was correct for the earlier container implementation, which could leave inactive backing bytes uninitialized and exposed staged compact mutation. PinaPod v0.2 removes those two blockers while preserving the existing wire format. The migration guide records the source changes, and the PinaPod safety boundary records the current invariants.

Alternatives considered

Copy-based deserialization into owned structs

Rejected because it adds compute, adds stack or heap pressure, and discards the in-place access model.

Arbitrary dynamic nesting

Deferred because recursive offsets, partial updates, and generated clients need one unambiguous layout. The first compact release supports the forms listed above. Future releases can add a form after native, Miri, code-generation, and SBF tests prove the full lifecycle.

Unsafe dynamic zero-copy for arbitrary layouts

Rejected because it moves layout, initialization, and aliasing invariants into caller discipline.

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 access
  • LoadedAccountMut<'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

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_code and unstable_features by 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
  • every token loader must guarantee canonical program ownership by using the selected program’s checked upstream account-view parser
  • the ATA loader must also validate the derived address, stored current authority, and stored mint
  • 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.

ADR 0007: Make ABI migrations first-class

Context

Solana programs cannot rewrite every program-owned account during deployment. Account data is loaded only when a transaction names its address, and every transaction has account, privilege, compute, size-growth, and fee constraints. A schema upgrade must therefore remain able to read old bytes and migrate each account on demand.

Account bytes are only one part of the compatibility contract. An old client also sends an old instruction payload for a specific process and positional account list. Events written by earlier executables remain in immutable transaction logs.

The desired developer model is declarative:

[migrations]
version_type = "u8"
#![allow(unused)]
fn main() {
#[account(discriminator = AccountType::Profile, migrations)]
pub struct Profile {
	pub authority: Address,
	pub score: u64,
}
}

A program that wants every contract versioned should not have to repeat the annotation:

[migrations]
version_type = "u8"
auto = true # or a staged subset such as ["accounts", "events"]

Amended (ABI 0.21): neither setting lives in pina.toml any more. pina migrations create --version-type and --auto record them in migrations/manifest.json, their only copy, and pina.toml refuses the retired keys. See the Amendment.

The developer declares that a contract is migratable, but does not choose or maintain its version. The current Rust source describes the desired state. Pina owns version allocation, ABI snapshots, generated structural transitions, drift checks, and historical dispatch. A developer supplies code only when a schema diff cannot determine the intended value.

Decision

Pina will treat migrations as a versioned ABI system, backed by a checked-in ABI history and a content-addressed publication history. Pina migrates stored account data, instruction payload data, and the generated instruction process contract when it can prove that an old positional account list remains a valid request.

The first process proof is deliberately narrow. Existing account slots must remain an identical positional prefix and newly appended slots must all be optional. An old request then represents the appended suffix as absent. Reordering, insertion into the middle, removal, renaming, privilege or signer changes, PDA changes, known-address changes, and newly required accounts are genuinely breaking under the same discriminator. The developer creates a new instruction discriminator for those changes. Pina may add new formally specified proofs later, but it will not expose compatibility policy switches.

Version envelope

Every opted-in wire contract uses this envelope:

[existing discriminator][schema version][payload]

The discriminator remains at offset zero. The version is little-endian and is hidden from generated accessors and patches. Version zero is the first migratable representation.

[migrations].version_type is global for the program. Pina initially accepts u8, u16, and u32. It does not accept per-account or per-instruction overrides. The width may change while the program has no published migration-aware release. The first persistent publication freezes the width, byte order, and header offset for that program identity.

Amended (ABI 0.21): the width is recorded only as the manifest’s versionType. pina migrations create --version-type rewrites it while no receipt or pending record exists and fails once one does.

Each account, instruction contract, and event advances independently. An instruction version snapshots both its payload and its process account ABI. Either a payload change or a compatible appended-optional process change advances the instruction version after publication.

Amended (ABI 0.21): only accounts, events, and instructions that opt in with migrations use the envelope; an instruction covered by auto alone is recorded as a single snapshot without one. An appended-optional process change now extends the published version in place instead of advancing it.

Existing unversioned data is not silently treated as version zero. Its first payload bytes may be a valid version by accident. Adoption requires an explicit legacy bridge or a new discriminator.

Auto opt-in policy

A [migrations].auto list opts whole contract kinds in without per-item annotations. The vocabulary is exactly accounts, events, and instructions, and auto = true is sugar for all three. Unknown names, duplicates, and mixing the boolean with a kind list are configuration errors. Staging a subset is supported because the kinds carry different costs: an instruction envelope changes payload bytes and ripples into CPI call sites.

Amended (ABI 0.21): an auto policy no longer envelopes instructions, so covering them does not change a payload byte. This staging rationale applies to instructions opted in individually with migrations. The policy is set with pina migrations create --auto, which takes true, false, or a comma-separated kind list, rather than a pina.toml list.

The resolved policy is recorded in migrations/manifest.json, and the manifest remains the only policy source procedural macros consult. Macros must not read pina.toml: the manifest is the checked-in, hash-chained document that keeps builds deterministic and reproducible, and a proc macro does not re-expand when an unrelated toml file changes, so a toml read would leave stale expansions after a policy flip. pina migrations create therefore records the policy and snapshots every contract of the listed kinds, and a declaration without a snapshot still fails the build with the existing pina migrations create remedy.

Amended (ABI 0.21): the manifest is now the policy’s only home, not only the one source macros read: create --auto writes it directly, and pina.toml keeps no second copy to disagree with it. Neither document is hash-chained; the manifest never was, and the publication ledger dropped its receipt chain.

Per-item migrations = false overrides the global policy for one contract. Removing an envelope from a contract the manifest already records is an error rather than a silent opt-out, because stripping an envelope changes the wire format. The same rejection applies when a kind is dropped from auto. Recording an envelope removal as a deliberate migration is a future retirement flow; this ADR only fixes the fail-closed behavior.

Enabling auto on an already-launched program is a bulk wire-format change: create records one history entry per newly enveloped contract. Existing live bytes of those contracts have no envelope, so the developer must treat the addition like any other deliberate wire-format change. For a new program it is simply the version-zero baseline.

Because the macros read the manifest, a policy flip must re-expand contracts without a source edit. When a policy is recorded, pina migrations create scaffolds a build.rs that emits cargo:rerun-if-changed=migrations/manifest.json. Scaffolding is idempotent, never overwrites a hand-written build script, and reports the exact line to add when it cannot write safely; pina migrations check fails until the directive is present.

Current IDL and ABI history

The public IDL describes only the current program contract. For each opted-in account or instruction, it includes migrationVersion as an omitted constant and a constant discriminator at the byte immediately after the ordinary discriminator. Generated clients therefore write the current version without exposing it as an application argument, while an IDL captured from an older release continues to write its own frozen version. The IDL does not contain historical schemas or transition code; it is not the migration database.

Amended (ABI 0.21): each earlier version of an event is listed in the IDL as its own event node, <Event>V<n>, so clients can decode old log records. Historical account and instruction schemas and all transition code remain outside the IDL. A snapshot-only instruction has no migrationVersion field.

The checked-in Pina ABI history records the physical information needed to reconstruct every supported representation:

  • ABI format and generator versions;
  • contract kind and stable identity;
  • discriminator bytes and width;
  • migration version width;
  • fixed or compact storage mode;
  • ordered canonical wire types;
  • fixed offsets and sizes;
  • compact prefix widths, capacities, header offsets, tail order, and alignment;
  • referenced enum representations and explicit discriminants;
  • the process contract for every instruction version, including accounts, positions, optionality, signer and writable requirements, known addresses, and PDAs;
  • the adjacent proof that relates each pair of process versions;
  • canonical schema and transition hashes.

Stable identity derives from contract kind and discriminator, not a Rust type name. Renaming a Rust type does not create a new on-chain identity.

The ABI history comes from the same closed schema grammar used by Pina’s macros. Format 3 records the pinaPodV2 codec and a derived, payload-relative physical descriptor. Format 4 records the [migrations].auto policy alongside it. Historical generated types assert their compiled fixed size or compact header, maximum size, and tail alignment against that descriptor. The Codama IDL and unconstrained Rust type strings are not precise enough to be the long-term physical-layout authority. PinaPod does not own Solana migration policy.

Pina’s ABI document has its own formatVersion, separate from every on-chain contract version. All readers decode the document into a generic envelope, reject future formats, and run Pina-owned adjacent format migrations before deserializing the current typed model. The ABI library also provides adjacent downgrade paths. A downgrade fails closed when an older format cannot represent the current document without information loss. Normal manifest writes always use the current format. An internal ABI-format upgrade therefore does not consume an account or instruction migration number, and old checked-in manifests remain buildable as long as Pina retains their adjacent document migrators.

Drafts and publication

Local iteration has one replaceable draft head per changed contract. pina migrations create captures the current ABI. It replaces an unpublished draft without consuming another version. If the current head has appeared in a persistent release, it allocates the next version.

pina build never creates or changes migration history. It fails on ABI drift, an unresolved custom transition, a modified published schema or transition, version exhaustion, or a version-width mismatch.

Amended (ABI 0.21): with the width recorded once, in the manifest, there is no second copy for it to mismatch. Drift compares what a field stores rather than how its type is spelled, so a respelling such as PodU64 for u64 is not drift.

Developers do not mark versions as published. Before a non-local deployment starts, pina deploy atomically records the exact ABI candidate as pending. The pending versions are frozen because they may already be live. After success, Pina rechecks every planned input and converts the pending record into a local receipt. Each receipt records:

  • the cluster label and credential-free RPC URL;
  • the program identity;
  • the SHA-256 digest of the exact planned executable;
  • the ABI manifest digest and every current contract version, together with the pinned schema and transition-implementation hashes of every published version;
  • the preceding receipt digest.

The checked-in, hash-chained publication ledger is the current source of truth for version allocation. Both receipts and a pending deployment freeze versions. Loopback local deployments do not add publication state.

Amended (ABI 0.21): a receipt records only the credential-free RPC URL, the executable digest, the pinned schema and transition hashes of every version it made live, and the abandoned flag; the pending record adds the cluster label. The program identity is the manifest’s programId, the manifest digest and the preceding receipt digest are gone, and a contract’s highest published version is the position of its last pin. The checked-in ledger stays the source of truth for version allocation, append-only through version control rather than a hash chain.

The first implementation does not query the deployed program-data account, cluster genesis hash, deployment slot, or transaction signature. A process interruption can therefore leave the remote outcome ambiguous. The pending record preserves and freezes the candidate before the remote command starts. Rerunning the exact deployment resumes it; Pina rejects a different deployment until the pending attempt is reconciled. The local ledger is reviewable release evidence, not an independent on-chain attestation.

Generated data compatibility boundary

The generated dispatcher, not an ordinary current-only decoder, owns historical instruction payload compatibility. Its conceptual flow is:

  1. Read the instruction discriminator and request version.
  2. Load the exact process snapshot for that instruction version and verify its frozen compatibility path to the current process.
  3. Decode the exact historical payload.
  4. Validate the historical positional prefix, signer and writable privileges, known addresses, PDAs, and duplicate mutable aliases; represent a proven appended optional suffix as absent.
  5. Inspect all typed migratable accounts required by the route.
  6. Preflight every account migration without retaining account-data borrows.
  7. Apply account migrations in generated deterministic order.
  8. Reload and validate every account in its current representation.
  9. Adapt the historical payload to the current command.
  10. Apply current authorization and business invariants.
  11. Run the current handler.

Application handlers see current types only. Historical instruction versions are attacker-selected input, so an adapter must not preserve obsolete weak authorization. Old bytes are decoded into a current command and then pass current checks.

The current-version hot path reads one version value and compares it with a generated constant. It does not scan history or trial-decode layouts.

Account migration runtime

Each generated adjacent account migration uses two phases:

#![allow(unused)]
fn main() {
pub trait MigratableAccount: private::Sealed {
	type Plan;

	const CURRENT_VERSION: u32;
	const VERSION_BYTES: usize;

	fn plan(data: &[u8]) -> Result<AccountMigrationPlan<Self::Plan>, ProgramError>;

	fn apply(plan: Self::Plan, destination: &mut [u8]);

	fn validate_current(data: &[u8]) -> ProgramResult;
}
}

Planning validates the exact source schema and returns owned, detached state plus that step’s target and working lengths. The plan cannot borrow account data. This lets the dispatcher drop every old borrow before funding, resize, mutation, or CPI. Applying a preflighted plan is infallible; the executor writes the adjacent destination version only after that representation validates.

The executor repeats this pair for each adjacent version in the bounded inline path. That sequencing is required for compact schemas: the allocation needed by v2 -> v3 can depend on the active tails produced by v1 -> v2, and copying a maximum-size account into stack scratch space is not viable on SBF. The first step may return a normal preflight error. Once any step mutates lamports, length, or bytes, a later planning, resize, or validation failure aborts the instruction and rolls back the complete transaction.

The executor has a one-way mutation boundary. Ownership, writability, historical decoding, step limits, size arithmetic, rent, funding authorization, and borrow availability fail with ordinary ProgramError values before that boundary. Once a funding CPI, resize, or byte rewrite succeeds, a later framework invariant failure aborts the instruction instead of returning a catchable error. This prevents application code from swallowing a migration error and committing partially rewritten bytes; Solana rolls every transaction effect back on the abort.

Generated structural changes include exact field copies, safe reordering, and defaults whose value is unambiguous. Type changes, narrowing, semantic splits or merges, authority changes, and other ambiguous changes generate an unresolved transition. A fixed-account transition is total and infallible after Pina validates its exact source shape. A manual instruction transition runs in scratch space and can reject a request. The build remains blocked until the developer implements the transition and provides semantic fixtures or invariants.

The default migration path retains every surplus lamport. It never uses the ordinary reallocation helper’s shrink-refund policy. Growth may charge only an explicitly declared, writable signer or a fixed program treasury policy, subject to a generated maximum. The target and payer must not alias.

Inline and dedicated execution

An ordinary instruction either completes all required migrations and its business handler atomically, or returns an error. It never returns success after migration without running the requested operation. Returning an error rolls migration writes back with the transaction.

Inline account migration is unavailable when any required condition is missing:

  • a stale account is readonly;
  • growth needs lamports and no authorized payer was supplied by the historical request;
  • growth exceeds the runtime’s per-instruction limit;
  • the bounded chain exceeds the supported compute or stack budget;
  • a custom transition needs an account absent from the historical process contract.

A stable migration instruction can advance a bounded amount of work and return success. Generated migration-aware clients may invoke it repeatedly, then retry the original operation. An old client cannot acquire this retry behavior after release. For that reason, Pina classifies compatibility at migration creation time instead of promising that every old request remains transparent.

Process compatibility

Pina versions the instruction account ABI with the instruction payload. The default adjacent proof accepts only two relationships:

  1. the process account ABI is byte-for-byte unchanged; or
  2. the destination retains the complete source list as an identical positional prefix and appends only optional slots.

The current Accounts parser treats missing trailing optional slots as None. Optional fields followed by another positional field still require the program-address filler, so absence cannot shift a later account into an earlier slot.

Every other relationship fails closed. Insertion into the middle, removal, reordering, slot renaming, signer or writable changes, PDA changes, known-address changes, and a new required account require a new instruction discriminator. Solana cannot synthesize a missing AccountView or escalate a privilege that was not present in the transaction. A change from the SPL Token program to Token-2022 therefore remains a new process unless both program slots already existed and the change is expressed entirely as current business logic.

Process compatibility proves only the generated account ABI. Arbitrary application semantics are not statically knowable. Every historical request is normalized and then runs current validation and authorization; developers must add semantic fixtures for manual migration logic.

CPI and mixed versions

Each program migrates only accounts it owns. Before a CPI, the caller migrates its own stale accounts and releases all typed guards. The callee’s generated boundary migrates its own stale accounts. After a CPI that received writable accounts, the caller reloads those accounts before further use because the callee may have changed their data and length.

For a current account interacting with a stale account, the dispatcher migrates the stale account before it constructs either current typed view. The handler therefore observes one coherent current model.

Account-local structural migrations cannot read or mutate unrelated accounts. A custom migration may declare read-only dependencies that are included in the historical request. Pina orders an acyclic dependency graph statically. Cycles and cross-account write invariants require an explicit coordinated migration instruction.

Events

Events are not migrated on-chain. New code emits only the current event representation. normalize_event_data and the generated with_current_event_data helper validate exact historical bytes and project them into caller-owned scratch space. The projection returns the source version with the latest-shape bytes, preserving whether a defaulted field was absent historically or was actually emitted with its default value. Golden event fixtures live in pina_test::HistoricalEvent; generated clients can apply the same manifest transitions off-chain.

Superseded (ABI 0.21): events are versioned, not projected. See the Amendment.

Compatibility verification

pina test --compatibility and pina_test consume checked-in historical fixtures, not fixtures regenerated from current code. The required suite covers:

  • every historical account version to the current version;
  • fixed, compact, fixed-to-compact, growth, shrink, and unchanged-size transitions;
  • current no-op, future-version rejection, truncation, malformed lengths, and maximum capacities;
  • rent deficit, surplus retention, unauthorized funding, aliasing, and arithmetic overflow;
  • historical instruction payload replay through every supported process version against the latest SBF artifact;
  • acceptance of appended optional account suffixes and rejection of required additions, insertion, reorder, removal, and privilege drift;
  • mixed-version sets, duplicate aliases, and missing privileges;
  • old caller programs performing CPI into the latest callee;
  • reload behavior after writable CPI resize;
  • rollback after failures injected at each migration phase;
  • event golden-byte decoding;
  • tampered histories, transitions, manifests, widths, and rollback releases;
  • compute, stack, and binary-size budgets for the oldest supported maximum-size state.

Native property tests cover parsers and state transitions. Miri covers borrow-guard lifetimes. Kani covers header arithmetic, bounds, and state machines. Mollusk and Surfpool exercise real SBF resize, rent, CPI, rollback, and old-client behavior.

Security consequences

Migration expands the program’s input surface to every supported historical layout. Generated code must reject unknown and future versions without fallback, validate the exact historical layout before reading fields, use checked length arithmetic, and validate the complete current representation after writing it.

Published transition code is immutable along with its source and destination schemas. Editing a live transition could let two accounts reach the same destination version under different semantics. A defect is repaired with a new version.

Account decoder history usually cannot be pruned safely because a Solana program cannot enumerate all of its accounts and prove that no old version remains. Instruction versions may be explicitly retired. Retiring account history requires an external completeness proof or an acknowledged risk of stranding old state.

An upgrade authority can bypass Pina and deploy arbitrary code. Publication guarantees therefore end at the program’s upgrade governance boundary.

Consequences

  • Developers manage desired schemas and semantic exceptions, not version integers.
  • Version bytes add permanent storage and request overhead to opted-in contracts.
  • Historical decoders and transitions increase SBF size and compute cost.
  • The runtime stays no_std and allocator-free.
  • PinaPod remains responsible for byte-layout validity; Pina owns versioning, rent, dispatch, publication, and compatibility policy.
  • Appended optional process accounts can share a discriminator; all process changes outside the proved relationship use a new discriminator.

Amendment (ABI 0.21, 2026-09-30)

ABI 0.21 narrows where the envelope applies and stops converting events. It also records the migration policy in the manifest alone and compares schemas by what they store. Accounts are unchanged: they keep the envelope, adjacent transitions, the reserved Migrate instruction, and MigrateAccount.

Instructions are snapshots unless they opt in

Under an auto policy, a plain #[instruction] is recorded with exactly one version and "envelope": false, and its wire format stays [discriminator][payload]. The snapshot still gates wire-breaking changes: the macro fails the build when the struct drifts from it, pina migrations create replaces it while nothing is published, and after publication create refuses a payload change (PublishedPayloadChanged) and asks for a new discriminator. Appending optional accounts extends a published version in place, for snapshot-only and enveloped instructions alike, because an older request that omits the new slots still parses.

#[instruction(discriminator = X, migrations)] opts one instruction into the envelope and the adjacent transitions this ADR describes. An envelope cannot be added to or removed from a published instruction (EnvelopeAddition, EnvelopeRemoval), because existing clients send exactly the bytes they were generated with. Programs published under ABI 0.20, where every recorded instruction was enveloped, keep their wire format by adding migrations to each such attribute; the build error names the contract.

The reason is cost without benefit. Most instructions never change their payload under one discriminator, yet the envelope added a byte to every request, a wire change at every CPI call site, and a conversion path to every dispatch. A new discriminator already expresses a new payload, and an instruction that must keep accepting old payloads can still opt in.

The dispatcher normalizes, not the handler

#[discriminator(entrypoint)] routes each instruction the manifest records with an envelope through its generated process_versioned, which converts a historical payload to the current layout and then calls ProcessAccountInfos::process_from_version(self, data, source_version: u32). The default implementation forwards to process(data), so handlers read current bytes without normalizing them. normalize_instruction_data returns a NormalizedInstruction with as_bytes(), source_version(), and was_migrated() for manual dispatch.

A transition that adds an instruction argument is always manual: a zero-filled argument is indistinguishable from a zero the client sent, so the developer chooses the value. Transitions that only move or drop bytes stay automatic.

Events are versioned, not migrated

The Events section above no longer describes the implementation. The program emits only the current version. Changing a published event appends a version with its own schema and no transition, so there is no migrations/transitions/event_* directory. normalize_event_data, with_current_event_data, MigratableEvent, and CurrentEventData are removed from the runtime.

Clients decode each version with its own schema. The IDL lists every earlier version as its own event node, <Event>V<n>, with its own codec, and the program-level log parsers (parse<Program>EventsFromLogs in TypeScript and Dart) dispatch each record by discriminator and version and fail closed on a version no generated event describes. Decoded records carry only the fields their version emitted, with no sourceVersion or wasMigrated. Client generation reads the event history from the IDL rather than the migration manifest. pina_test::HistoricalEvent fixtures are decoded with the generated event for their version.

Projection could only present a field an old event never emitted as zero, and a manual event transition left generated clients unable to decode that version at all. Decoding each version with the schema that emitted it is exact and needs no transition code.

Document shape

The ABI 0.21 manifest stores each fact once: the contract key is the identity, the wire codec is implied by abiVersion, instruction histories record "envelope": false when they have no envelope, and a process slot omits every field left at its default. The publication ledger keeps only what nothing else records. ADR 0009 records those changes.

The policy has one home

The auto policy and the version width are recorded only in migrations/manifest.json. pina migrations create --auto <POLICY> sets the policy (true, false, or a comma-separated list of accounts, events, and instructions), and --version-type <u8|u16|u32> sets the width; omitting a flag keeps what the manifest records. The width can change only while no receipt or pending record exists. pina.toml no longer accepts [migrations].auto or [migrations].version_type, and every command that reads pina.toml names the flag that replaces a retired key. The VersionTypeChanged and AutoPolicyChanged checks are gone, because there is no second copy to disagree with, and so is the IDL refusal for a pina.toml policy without a manifest: without a manifest there is no policy anywhere.

Keeping the policy in two places meant a check that they agreed, and the manifest still had to be regenerated before the pina.toml copy meant anything. One copy removes both, and it keeps the property this ADR relies on: macros read only checked-in bytes.

Because a hand edit could still change the manifest’s auto, the macros refuse to strip a recorded envelope that way. A declaration without a migrations token that the recorded policy does not cover, but that the manifest records with an envelope, fails the build, just as an explicit migrations = false does.

Equivalent spellings are one schema

Drift is decided by what a field stores. pina_abi::wire_type maps spellings that store the same bytes under the same reading to one form — the Pod* integer and boolean wrappers to their native names, Address to [u8; 32], PodString<N> to String<N>, and PodVec<T, N> to Vec<T, N>, recursively — and DataSchema::same_wire compares layouts, field names, and those forms. The macros’ snapshot check, pina migrations create and check, rename pairing, and the automatic transition planner all use it, so a respelling consumes no version and fails no build. Recorded spellings and pinned hashes are never rewritten. Types that only share a width, such as u64 and i64, still need a manual transition, because reading one as the other reinterprets a live value.

Rejected alternatives

Version fields written by developers

This duplicates state already known by the migration history and permits source constants to drift from deployed bytes.

Per-contract version widths

This saves at most a few bytes while making generic inspection, generated dispatch, configuration, and publication harder to reason about. A program-global width is a simpler permanent contract.

Loader-only migration

A loader cannot adapt historical instruction payloads. It may also retain a borrow when resize or CPI needs exclusive access.

Configurable account-list adapters

User-selected compatibility modes make the authorization boundary depend on configuration and make two Pina programs interpret the same diff differently. Pina instead ships a small set of versioned, tested proofs. The initial proof covers only an identical prefix plus an optional suffix.

Client-only migration

This does not support old clients or on-chain CPI callers and lets a program receive stale accounts without a safe transition path.

Migration policy in PinaPod

PinaPod should describe and validate physical representations. Solana ownership, privileges, rent, deployment receipts, and instruction dispatch belong in Pina.

Automatic downgrade

Downgrade expands the attack surface and can discard information. Rollback must deploy an executable that still understands the latest published ABI rather than rewriting accounts to an older schema.

ADR 0008: Migration ergonomics, client-driven migration, and legacy adoption

  • Status: Proposed; amended 2026-09-30 for ABI 0.21 (see Amendment)
  • Date: 2026-09-10
  • Owners: Pina maintainers
  • Related: ADR 0007

Context

ADR 0007 established the versioned ABI, on-demand account migration, and the publication ledger. Dogfooding the full developer loop while advancing the migrations example to a second generation confirmed the core experience:

  • opting in is one annotation and one pina.toml setting;
  • forgetting pina migrations create fails the build with the exact remedy;
  • drafts are replaceable, published history is pinned, and deploy records publication automatically;
  • inline migration works with an explicitly capped payer.

Three gaps remain between that experience and the intended mental model — “turn migrations on, then stop thinking about them”:

  1. A stale account whose business instruction carries no authorized payer cannot migrate. Every instruction that touches a migratable account must thread a migration_payer slot through its process contract, or the instruction fails with MigrationRequired. ADR 0007 describes a stable migration instruction that clients invoke before the real operation; that instruction was never implemented.
  2. Clients have no migrate-first flow. Generated clients know each account’s current version (the IDL already carries it as an omitted constant) but never act on it.
  3. Programs launched before migrations cannot adopt them. ADR 0007 explicitly refuses to interpret unversioned bytes as version zero, so an existing program that adds migrations strands every live account.

Decision

Reserved migration instruction

Pina reserves one instruction discriminator per program — the all-ones value of the program’s instruction discriminator width — for a framework-generated Migrate instruction. The #[discriminator] macro rejects a user variant that claims the reserved value. Because no Pina program is live today, the reservation is not a breaking change; it is part of the migrations feature contract from its first release.

When a program declares at least one migratable account, the framework generates:

pub mod __pina_migrate {
	// data: [reserved discriminator] (no payload)
	// accounts:
	//   [0] payer — writable signer, optional when no step needs funding
	//   [1..] program-owned accounts to migrate, each self-describing
	//         through its account discriminator
	pub fn process(
		program_id: &Address,
		accounts: &mut [AccountView],
		data: &[u8],
	) -> ProgramResult;
}

The handler dispatches each account by its leading discriminator to the matching MigratableAccount implementation and runs the same executor the inline path uses, with the same step, growth, and lamport caps. Accounts whose discriminator matches no migratable contract, accounts not owned by the program, and duplicated mutable accounts fail closed. The developer wires one match arm in the program entrypoint (Migrate => __pina_migrate::process(...)) so instruction routing stays explicit, and pina migrations create fails the build until that arm exists.

This is the instruction clients prepend when an account is stale: the payer authorizes exactly the migration cost, then the business instruction runs without migration plumbing in its own account list. Business instructions keep their inline-migration behavior; the dedicated instruction is the escape hatch for payers that the historical request could not name.

Client migrate-first flow

Generated clients gain a migrateIfNeeded helper per program:

  1. fetch the program-owned accounts named by the operation;
  2. compare each account’s version envelope against the client’s frozen current version (already embedded in the IDL as an omitted constant);
  3. build a transaction of [Migrate { payer }, ...real instructions] when at least one account is stale, otherwise send the real instructions alone.

The helper never migrates silently at rest: migration happens inside a transaction the caller signs, with the payer the caller chose, and the real operation follows in the same transaction so it observes current data or the whole transaction fails.

Legacy adoption

A program that launched without migrations adopts them with #[account(discriminator = ..., migrations, legacy)]:

  • The first pina migrations create records a legacyBase schema — the pre-migration wire layout without the version envelope — alongside version zero, whose payload is the legacy payload unchanged.
  • The generated planner recognizes legacy bytes before any versioned layout: exact-shape validation of the legacy base runs last, after every versioned current and historical layout has had its exact chance. Two layouts accepting the same bytes is an ambiguity error at generation time, so runtime detection stays deterministic.
  • The legacy-to-v0 transition is generated: insert the version envelope (version zero) and shift the payload; growth follows the ordinary rent and payer rules.
  • Instructions and events adopt the same way. Unversioned historical instruction data is shorter by the version width, which the existing exact-length historical replay already distinguishes.

Amended (ABI 0.21): instructions and events no longer adopt through a legacy-to-v0 transition. See the Amendment.

Existing accounts migrate on their next touch with no client change; a client that keeps sending legacy instruction data keeps working until the program retires that history explicitly.

Amendment (ABI 0.21, 2026-09-30)

ABI 0.21 changes what legacy adoption has to cover. Accounts are unaffected: the reserved Migrate instruction, the client flow, and the legacy design above still apply to them.

  • Instructions. Under an auto policy an instruction is recorded as a snapshot without an envelope, so its wire format stays [discriminator][payload], the same bytes a program launched before migrations accepts. Covering a launched program’s instructions with auto therefore needs no bridge: the snapshot records the live payload and gates later changes. Only an instruction that opts in with #[instruction(discriminator = X, migrations)] gains an envelope, which changes its live wire format, so it still needs a new discriminator or a deliberate bridge. Once an instruction is published as a snapshot, create refuses to add the envelope (EnvelopeAddition).
  • Events. Events are versioned, not migrated: the program emits only the current version and generated clients decode each historical version with its own schema. A legacy event therefore needs a generated decoder for its unversioned layout rather than a legacy-to-v0 transition.
  • Opting in. The context above describes opting in with one annotation and one pina.toml setting. The policy now lives only in the manifest: pina migrations create --auto records it and --version-type records the width, and pina.toml refuses the retired [migrations].auto and [migrations].version_type keys. The mental model is unchanged: turn migrations on once, then stop thinking about them.

Security consequences

  • The reserved discriminator removes a value from every program’s instruction space; the discriminator macro enforces the reservation at compile time.
  • Migrate is a privileged-sized surface: it mutates only program-owned accounts, caps lamports per invocation, and refuses accounts that fail ownership or discriminator checks before any mutation.
  • Client migrateIfNeeded decisions are advisory; the on-chain boundary re-validates every version and shape, so a stale or malicious client cannot downgrade data by skipping migration.
  • Legacy detection must be unambiguous by construction: generation fails on any byte-shape overlap between the legacy base and a versioned layout.

Open questions

  • Should Migrate accept a maximum-lamports argument, or is the generated per-program cap sufficient? (Current inline path uses a compile-time cap.)
  • Should migrateIfNeeded batch multiple stale accounts into one Migrate per transaction, or one per account to bound per-transaction rent exposure?
  • Does legacy adoption need an off-chain bulk-migration command (pina migrations sweep) for programs that want to pre-migrate instead of migrating on touch?

Implementation order

  1. Reserved discriminator enforcement in #[discriminator] and the Migrate handler generated for migratable programs.
  2. migrateIfNeeded in the Rust client, then TypeScript and Dart.
  3. legacy adoption: manifest legacyBase, planner detection, generated legacy-to-v0 transition, and an adopted-after-launch example test.

ADR 0009: Version the ABI document on pina_abi’s own release line, reset its shape, and publish generated schemas

Context

ADR 0007 established that Pina’s ABI document carries its own formatVersion, independent of every on-chain contract version, and that readers normalize historical formats through Pina-owned adjacent migrations before deserializing the current typed model. That machinery exists and works: MANIFEST_FORMAT_VERSION is 4, PUBLICATION_FORMAT_VERSION is 3, and decode_manifest walks a document from any older format up to the current one.

Five gaps remain between that implementation and the guarantee a reader actually needs.

  1. The format number is an arbitrary integer with no relationship to a release. "formatVersion": 4 does not tell a reader which release wrote the document or which release can still read it, and nothing prevents the counter from advancing for a non-breaking reason. The fix is not to derive a value from a version pina_abi does not control: today pina_abi carries version = { workspace = true } inside the ~25-crate core group, so its version moves on every core release — a CLI fix, a renderer tweak — and any value derived from it would advance for reasons unrelated to the document contract. pina_abi needs a release line of its own before any version pin is meaningful.
  2. No historical documents are checked in. Every one of the 24 checked-in example manifests is format 4 and every publication ledger is format 3. The 1 -> 2 -> 3 -> 4 chain is exercised only by unit tests that construct documents in memory; it has never run against bytes an older release actually wrote. Formats 1 and 2 never shipped in any tagged release at all — pina_abi was introduced by v0.16.0 already at format 3 — so their readers and the entire downgrade ladder serve documents that provably never existed.
  3. The chain is not proved gapless, and half of it is uncalled. No test asserts that every format in 1..=4 is covered, and the explicit downgrade entry points (convert_manifest_format, convert_publication_ledger_format) have no callers outside pina_abi’s own tests. Nothing in CI runs pina migrations check, create, or sync against the repository’s 48 checked-in documents — the nightly walkthrough drives a scratch copy — so document drift has no gate.
  4. The document stores its own derivations as frozen caches. DataSchema.physical is recomputed from layout plus fields and rejected when it disagrees — measurements across migrations_program show it is 45–75% of every schema snapshot’s bytes. Every transition re-records the neighbouring schema and process hashes it is validated against, a version field that must equal its vector index, from/to numbers that must equal adjacency, and a frozen process proof that validate re-derives and compares. None of it adds information; all of it adds size and a second copy of every invariant.
  5. No JSON Schema exists for external consumers. The document is JSON, but nothing can validate it outside Rust: no schema artifact, no published URL, no command that prints one.

No Pina program is live, and ADR 0008 already relies on that when it reserves an instruction discriminator. The claim this ADR needs is narrower than “no published crate”: pina_abi is published, so a third party could in principle have unpacked a manifest to read it, but no checked-in document describes a deployed program — the single non-empty receipt in the repository records the fixture cluster — and every checked-in document is regenerated from source by pina migrations create. Nothing outside this repository depends on the integer values or the stored derivations, so retiring both is free today and expensive once a real program pins them.

Decision

pina_abi leaves the core group and owns its release line; the ABI document version is a committed value pinned to that line; converters run between every adjacent pair of values, validating as no-ops where the document shape did not change; the baseline shape drops every stored derivation; and a JSON Schema for each version is generated, checked in, and published at a versioned URL.

pina_abi leaves group.core

The structural precondition. monochange keeps its schema crates (monochange_schema, monochange_classification) deliberately outside its grouped release train; pina’s topology is currently the opposite. The reset moves pina_abi to a single-member group with its own changelog, the [group.snapshot] pattern:

  • crates/pina_abi/Cargo.toml becomes a literal version = "0.20.0" instead of { workspace = true };
  • the root workspace dependency pin (Cargo.toml’s pina_abi = { path = "crates/pina_abi", version = "…" }) gains a versioned_files rule so the release planner bumps it in lockstep with the crate;
  • pina_cli and pina_macros consume pina_abi, so a pina_abi release cascades into a core release. That direction is the point: the contract moves deliberately and consumers follow, while core releases never move the contract.

Starting at 0.20.0 continues the workspace’s version neighborhood and gives the reset version a clean name. It must not jump to 1.0.0; see the bump table below.

A committed value, not a derived one

Both documents replace their independent integers with one shared string field:

{
	"abiVersion": "0.20",
	"programId": "...",
	"versionType": "u8"
}

The value lives in a one-line committed file, crates/pina_abi/ABI_VERSION, embedded with include_str!:

pub const ABI_VERSION: &str = include_str!("../ABI_VERSION").trim_ascii_end();

The file is pinned to pina_abi’s major.minor — and, crucially, it is allowed to lead the crate. A shape-changing PR merges while Cargo.toml still reads the old version; the release PR bumps the crate afterwards. monochange works exactly this way and verified the state repeatedly (SCHEMA_VERSION at 0.7 while the crate sat at 0.6.3), because a value that cannot lead its crate forces merged source to stamp new shapes with yesterday’s version. Derivation was tried there twice — a build.rs reading CARGO_PKG_VERSION, then extract_major_minor(env!("CARGO_PKG_VERSION")) — and reverted both times: once so an already-merged release commit embedding an older schema version stayed readable, and once because the build script was excluded from package.include and cargo publish failed outright. The crate that still derives a snapshot version records the core objection in its own config: a major changeset would “advance the published command snapshot contract … without any wire change.” Derivation advances the contract for reasons unrelated to the contract.

The stamp is written by the tool and read by the tool. An older Pina rejects a document stamped above its own version, failing closed with the supported version named in the error — the same contract as Cargo.lock’s version field. A repository’s documents are read by the same Pina the repository builds with, so the rejection only reaches a human who downgraded, and the remedy is one line: upgrade.

Pre-1.0 bump semantics: the version moves if and only if the contract moved

The release planner shifts bump severity only while the major is 0 (breaking/major → minor, feat → patch). Combined with a pina_abi on its own axis, this gives exactly the wanted property:

changeset on pina_abiat 0.20.0abiVersionat 1.0.0abiVersion
breaking0.21.0"0.21" ✓2.0.0"2.0" ✓
feat0.20.1"0.20" ✓1.1.0"1.1" ✗
fix0.20.1"0.20" ✓1.0.1"1.0" ✓

Pre-1.0, only a breaking changeset advances major.minor, so the ABI version moves if and only if the pina_abi contract moved. At 1.0.0 the shift switches off and a feat — say, making physical_layout public, no wire change — would advance the ABI version and restamp every document. Jumping to 1.0.0 is therefore rejected; it is not even reachable through the machinery, since pre-1.0 a major changeset yields a minor bump, so reaching 1.0.0 would require hand-editing Cargo.toml past the planner that enforces all of this. Staying 0.x also keeps the versioned-URL policy publishing every ABI version permanently (0.N always, N.0-only after 1.0), and every ABI version is a real contract worth a permanent URL.

The converter contract: adjacent edges, no-ops included

The reader carries an ordered table of adjacent converters, one per ABI version step, and reading is reject-then-walk: reject a stamp newer than the reader, apply each converter from the document’s version to current, deserialize the typed model, validate the invariants. There is no separate epoch table — the version itself is the only axis, because it now advances exactly at contract changes.

Because the version follows every breaking pina_abi release but the document shape changes only sometimes, validating no-op edges are required, not avoidable. monochange’s v0.6 → v0.7 edge is the precedent: payloads unchanged, version advanced because a config contract changed, and the edge exists so the 0.6 contract stays frozen while the walk stays gapless. A no-op edge validates the document and returns it; that is all.

Edges run forward only and are never deleted. Writers always emit the current version, and a document that must reach an older tool is regenerated from source by that tool.

The guards, ported from monochange

  1. Not-lag guard. The committed value must not lag the crate’s major.minor. After a release the two are equal.
  2. Ahead-requires-changeset guard. A value ahead of the crate is the legitimate pre-release window — and must have an active pina_abi changeset behind it so the release actually catches the crate up. Any other ahead state fails.
  3. Fixture-drift guard. Regenerating the canonical current/ fixture must reproduce the frozen bytes unless the committed value advanced. The fixtures prove the shape changed; the committed value only names it — so a serialization change without a value change fails CI, while a value change with unchanged bytes is exactly the no-op-edge case, registered deliberately.
  4. Gapless-walk guard. Every frozen fixture decodes through decode_manifest or decode_publication_ledger to the current model, so a missing or mis-wired edge is a test failure, never a user’s runtime error.

The lean baseline shape

The 0.20 baseline stores facts and derives everything else at load:

  • DataSchema keeps {layout, fields, codec} and drops the stored physical; the existing derivation becomes the public source for offsets, sizes, and capacities, and codec remains the semantic pin so a future layout change is a loud version event, not a silent reinterpretation.
  • SchemaVersion drops version (it is the vector index), schema_sha256, and process_sha256 (they are computed at load; publication receipts compute their pins from the decoded content).
  • Transition keeps {mode, renames, implementation_sha256} and drops from/to (adjacency), the four neighbouring schema and process hashes, and the frozen ProcessTransition proof (re-derived by classify_process_transition).
  • ProcessAccount drops constraints. The migration ABI records wire facts — name, writable, signer, optional, default address, PDA identity — because those decide whether old bytes and old account lists still parse. Declarative validation rules are program semantics, live in the IDL clients generate from, and previously made a validation-only change demand a new instruction discriminator.
  • Receipts drop the cluster label and keep the credential-free rpc_url.
  • rust_name, the auto policy, identity validation (including the path-traversal proofs), the receipt hash chain, the pending record, and the abandoned flag all stay.

Amended (ABI 0.21): codec and the per-history identity object have since left the document as well, process slots omit their defaults, and the ledger no longer stores the receipt hash chain, sequence, programId, manifestSha256, or a per-contract version. See the Amendment.

Because deny_unknown_fields applies, any document field change is breaking for older readers, and every document change therefore advances the ABI version through a breaking changeset. The converse does not hold — a breaking crate change with an unchanged document is the no-op edge.

Generated, published JSON Schemas

The document types derive schemars::JsonSchema, so deny_unknown_fields becomes additionalProperties: false and the schema is generated, never hand-written:

  • canonical artifacts checked in at crates/pina_abi/schemas/{manifest,publications}.schema.json, regenerated and drift-checked the way Codama IDL fixtures are;
  • one frozen copy per version inside the fixture matrix, so a historical shape’s schema stays printable forever;
  • pina abi schema [--document manifest|publications] [--version <major.minor>] prints the JSON, stable for redirection;
  • every version’s schema hosted at a permanent URL under the book, https://pina-rs.github.io/pina/abi/schemas/<version>/manifest.schema.json, with $id set to that URL. External tools — editors, CI, other languages — validate documents against the URL instead of trusting the bytes.

The one-time rebase

Because no program is live and no consumer depends on the current shapes, the integer formats are retired rather than migrated:

  • pina_abi moves out of [group.core] in monochange.toml onto its own single-member group, with a literal version = "0.20.0" and a versioned_files rule covering the root workspace pin;
  • MANIFEST_FORMAT_VERSION, PUBLICATION_FORMAT_VERSION, the format 1–3 readers, and both downgrade ladders are deleted, along with the private DataSchemaV2, PublicationLedgerV1/V2, and PublicationReceiptV1/V2 shims — roughly 700 lines and their tests;
  • all 24 checked-in example manifests and ledgers are regenerated at abiVersion 0.20 by pina migrations create;
  • a hypothetical pre-0.20 ledger that pinned a real deployment would have no upgrade path. None exists — the only non-empty receipt records the fixture cluster — and accepting that stranding is the price of the reset, paid once, now.

Amendment (ABI 0.21, 2026-09-30)

ABI 0.21 is the first real step in the converter table: ABI_STEPS holds one AbiStep { from: "0.20", to: "0.21", manifest, publications }, and each step now carries a converter per document because both documents share one abiVersion. walk_document takes an AbiDocument and applies the matching converter. The step continues the lean baseline by removing the facts 0.20 still stored twice:

  • The contract key is the identity. Each history’s identity object (kind, discriminatorBytes, discriminatorHex) repeated its kind:width:hex key. It is dropped; ContractIdentity::from_key parses and validates the key, and ContractHistory::identity is filled from it when a manifest is read. Identity validation, including the path-traversal proofs, is unchanged.
  • The wire codec moves into abiVersion. Every schema stored "codec": "pinaPodV2", a value no reader could vary. It is now implied by the document version (pina_abi::SCHEMA_CODEC) and kept only in the schema hash preimage, so every published schemaSha256 keeps its value. The earlier rationale for storing codec — a loud version event for a layout change — now holds through the version itself: a PinaPod release that changes wire bytes becomes a breaking pina_abi release, and its converter records the old format on the historical schemas it carries forward. DataCodec is removed.
  • Absent transitions are absent. A version with no transition no longer writes "transition": null.
  • Events carry no transitions. An event history keeps one schema per version, because an old log record is decoded with the schema that emitted it. The manifest converter drops recorded event transitions.
  • Instructions may omit the envelope. ContractHistory gains envelope, written only as "envelope": false. Such an instruction has exactly one version and no transitions. Every 0.20 instruction history was enveloped, so the converter carries instructions forward unchanged.
  • Process slots omit their defaults. writable, signer, and optional are written only when true, and defaultValue and pda only when set; a reader treats an absent field as false or none. Process hashes are never pinned, so no published hash changes.
  • The ledger keeps only what nothing else records. A receipt is its credential-free rpcUrl, its executableSha256, its pinned versions, and abandoned; the pending record carries its cluster instead of abandoned. sequence repeated the receipt’s position. programId repeated the manifest’s, and the ledger belongs to the manifest beside it. manifestSha256 hashed a manifest that later versions rewrite, and nothing compared it. previousReceiptSha256 chained each record to the one before it, but anyone able to edit the file could recompute the chain, so it guarded nothing version control does not. Each contract’s entry is now the bare list of its pins: entry n pins version n, so the highest published version is the position of the last pin, and an empty list is invalid because every receipt pins every version it made live.

The publication ledger converter drops each of those copies, proving it first where there is something to prove: sequence must equal the receipt’s position, every record must name the same programId, and each contract’s version must equal the position of its last pin. It drops manifestSha256 and the chain link unchecked, and it drops the pinned transitionSha256 of every event:* contract. A 0.20 entry that pinned nothing cannot be represented, so the converter refuses it with an error naming pina migrations reconcile --pin-legacy, which pins such entries from the manifest (pina_abi::pin_legacy_publications) once the operator has confirmed the manifest against version control, and writes the ledger in the 0.21 shape. Dropping the chain unchecked is what lets that repair fill in pins without re-sealing a chain no reader keeps.

The manifest converter checks each stored copy it drops against the fact it duplicates — an identity must spell its key and a codec must be pinaPodV2 — so an inconsistent 0.20 document fails the walk rather than normalizing into a valid 0.21 one. Readers convert 0.20 documents in memory; the next pina migrations create writes the 0.21 shape. The frozen fixtures/0.21/ directory and the hosted abi/schemas/0.21/ schemas record it.

Consequences

Benefits:

  • a stamp names a real pina_abi release, and the version advances if and only if the contract moved;
  • the committed value can lead the crate, so merged source never stamps a new shape with yesterday’s version;
  • core releases stop touching the ABI version entirely;
  • every contract change is forced through the gate: converter, fixtures, schema, and a breaking changeset, with CI proving the chain against frozen bytes;
  • the manifest roughly halves, because derivations are no longer stored twice;
  • any consumer, in any language, can validate a document against a published, versioned JSON Schema;
  • downgrade code that nothing called is gone.

Costs:

  • two version numbers exist — the crate’s semver and the ABI major.minor — and the not-lag and ahead-requires-changeset guards are what keep them honest;
  • a pina_abi release cascades into a core release, so the contract cannot move without releasing its consumers;
  • a breaking crate change with an unchanged document must still register a validating no-op edge; the fixture-drift guard catches the omission, but the ceremony is real;
  • schemars becomes a direct dependency of pina_abi, pinned so generated schemas stay byte-stable.

Alternatives considered

Keep the unbounded integer counter

This is the current state. It works for a single maintainer tracking two integers, and it fails the question a third-party tool needs answered: which release wrote this, and is my reader new enough. Nothing stops the counter advancing for a non-breaking reason, which is how a format number loses its meaning.

Derive the version from CARGO_PKG_VERSION at build time

An earlier draft of this ADR chose this. It eliminates the second version source, but it cannot represent the design’s working state — a value ahead of its crate during the pre-release window — and monochange tried it twice and reverted both times (a build.rs on CARGO_PKG_VERSION, then env!("CARGO_PKG_VERSION")), once to keep an already-merged release commit readable and once after cargo publish failed because the build script was excluded from the package. In pina it is strictly worse: pina_abi sits in group.core with a workspace version, so a CLI fix or renderer tweak would restamp all 48 checked-in documents with no CI gate on the migration commands to catch the drift. Derivation advances the contract for reasons unrelated to the contract.

Keep pina_abi in group.core

Any pin between the ABI version and pina_abi’s version is incoherent while that version moves with ~25 unrelated crates. Moving pina_abi onto its own single-member group is the structural change that makes “ABI version equals pina_abi’s major.minor” true, mirroring how monochange keeps its schema crates outside its release groups.

Jump to 1.0.0

Rejected by the bump table above: post-1.0 the pre-stable shift switches off and a feat on pina_abi would advance major.minor with no wire change, restamping every document. 1.0.0 is not reachable through the planner anyway — pre-1.0 a major changeset yields a minor bump — so taking it means hand-editing Cargo.toml past the machinery doing the work.

Independent manifest and ledger axes

Separate values double the committed files, converter tables, fixture matrices, and schema pairs to preserve a distinction no reader uses. The two documents change shape rarely and always in the same crate; one shared axis answers both.

Keep the stored derivations

The frozen physical descriptor and the duplicated hashes are tamper-evidence: a document that lies about its layout is rejected. But validate re-derives every one of them at load, publication receipts pin hashes computed from decoded content, and codec pins semantics — the stored copies are a cache of checks that run regardless, at roughly half the document’s size.

Keep the downgrade converters

Downgrades fail closed when they would lose information and nothing calls them. Writers always emit the current shape; a document that must reach an older tool is regenerated from source by that tool. Forward-only halves the surface that must be kept correct forever.

Open questions

  • Resolved in ABI 0.21: a receipt’s manifestSha256 did not become a live assertion. It hashed a manifest that later versions rewrite, so making it live would have invalidated every receipt on the next document change; the 0.21 ledger drops it, and the per-version schema and transition pins, which every check compares, carry the guarantee instead.
  • Should deny_unknown_fields be relaxed so genuinely additive fields do not force a version advance? The strict reading is chosen because the documents are tool-written and tool-read; there is no third-party writer to be lenient with.
  • Do the retired integer formats need fixtures preserved for forensic reading of an old commit, given that no artifact outside this repository ever carried one?

ADR 0010: Lean entrypoint strategy for deployed-size parity

  • Status: Accepted (decisions 1–2 shipped or measured; the dispatcher is decided against)
  • Date: 2026-09-27 (lean-dispatcher measurement added 2026-09-28; decision 1 amended 2026-09-29; decision 2 revisited by ADR 0011 on 2026-09-30)
  • Deciders: Pina maintainers
  • Related: Framework comparison, Program size

Context

After the fat-LTO conversion, the comparison fixtures measure:

FixturePinaPinocchioQuasarAnchor v2
hello world4,6803,1602,5201,880
counter12,7206,5127,8088,696

Pina is the largest program in both rows. The question this ADR answers: what would the project look like if it targeted the same ELF size as Anchor v2 and Quasar, and is pinocchio the bottleneck?

Disassembly answers the second part directly: pinocchio is not the bottleneck. Anchor v2 itself is built on pinocchio 0.11 and still ships a 1,880-byte hello world. The bottleneck is how the entrypoint uses pinocchio, plus layers of cost that are pina’s own.

Where the bytes actually are

Symbol-level breakdown of the counter fixture’s .text:

SymbolBytesWhose cost is this?
entrypoint (pinocchio program_entrypoint! + inlined pina dispatch)7,736mixed — see below
program_error_to_u64864pinocchio error conversion
3 × RangeInclusive<usize>::index648pina/pinapod envelope slicing
assert_owner / assert_address400pina validation
combine_seeds_with_bump304pina CPI
slice-index panic plumbing~450RangeInclusive support

The 7,736-byte entrypoint decomposes further. Pinocchio’s deserializer walks the account region with a compile-time-unrolled process_n_accounts! macro — five accounts per macro expansion, remainder handled by match arms — so a program that declares MAX_TX_ACCOUNTS (the default, 255) carries unrolled walking code for 255 accounts even when every instruction uses two. Quasar’s hello-world entrypoint is 1,096 bytes; pina’s is 2,560. Anchor v2’s is 32 bytes: a stub that forwards to an #[inline(never)] dispatcher.

The budget is the cheapest lever and it already exists. nostd_entrypoint! has always accepted a second argument for the account budget; the fixtures never passed one. The prototype (benchmarks/framework-comparison/programs/*/pina_lean, built with the exact comparison profile and proven by the comparison verifier) measured it: hello 4,680 → 2,736 bytes (−41.5%) with budget = 1, counter 11,400 → 9,976 (−12.5%) with budget = 3, at +6 CU and −93/+4 CU respectively. Any program can take this today with no framework change.

The seed-and-signer slicing fix shipped alongside it. The PDA-creation CPI spine sliced its seed and signer arrays with inclusive ranges ([a..=len]), monomorphizing a 216-byte RangeInclusive<usize>::index copy per element type — three copies, ~1.3 KB, in every PDA-creating program. Exclusive bounds ([a..len + 1], guarded by the len < MAX checks that already ran) measured −1,320 bytes and −92 CU on the counter’s initialize with byte-identical behavior, and now live in the shipped crate.

Two candidate levers measured out and were dropped. A compact cold error converter: the exhaustive ProgramError → u64 switch costs 864 bytes, a small-immediate-plus-shared-shift reformulation costs 832, and Anchor v2 pays the same 864 — the conversion is table stakes for the ABI, not a differentiator. Unifying assert_owner/assert_address instantiations: their bulk is the inlined 32-byte address comparison on the success path, which cannot be outlined without adding compute units to every check; the shareable log tails are ~24 bytes each.

What Quasar and Anchor v2 do differently

Five techniques, all compatible with keeping pinocchio where it earns its keep:

  1. Do not eager-deserialize all accounts. Pinocchio’s program_entrypoint! builds AccountViews for every account before the program’s code runs. Anchor v2 never calls it: its __anchor_dispatch stub (32 bytes) receives the raw *mut u8 input and hands it to an #[inline(never)] dispatcher that walks accounts lazily through an AccountCursor — exactly HEADER_SIZE accounts per instruction, and the remaining-account walk is a tight runtime loop, not an unrolled macro. Quasar’s dispatch! is per-arm: each instruction arm parses precisely COUNT accounts with one shared loop, in place, into a [MaybeUninit<AccountView>; COUNT] on the stack.

  2. 32-byte entrypoint stubs. Both frameworks generate entrypoint as a thin extern "C" function that jumps to the real dispatcher, keeping the BPF loader’s required symbol tiny and isolating the stack-heavy body out of the frame the loader sees.

  3. Per-instruction account-count constants. Quasar’s dispatch! arms each know <$accounts_ty as AccountCount>::COUNT — a compile-time constant per arm — so Initialize in the counter fixture parses 3 accounts, Increment parses 2, and no code for a 64-account instruction exists anywhere.

  4. Shared, loop-based account walking. Where pina inherits pinocchio’s unrolled walk, both competitors walk accounts with a single compact loop that LLVM keeps small. The unrolled form is deliberately CU-cheaper (fewer jump instructions) but costs roughly 1.4 KB of .text for MAX_TX_ACCOUNTS-scale arrays — a size/CU trade pinocchio resolved in favor of CU.

  5. Error-path outlining. Anchor v2 and Quasar funnel every failure through one #[cold] conversion function (ProgramError → u64), one panic handler, and per-arm dispatch that never inlines. Pina already does this in places (remap_custom_error is #[cold]), but its Accounts derive still inlines per-field assertions into the caller’s frame.

Measured prototype

The prototype fixtures measured the budget lever in isolation — everything else stock — with the verifier proving each instruction ran and left the expected state:

FixtureStock (default 255)Lean (bounded budget)ΔCU stock → lean
hello (nostd_entrypoint!(process_instruction, 1))4,6802,736−41.5%145 → 151
counter (nostd_entrypoint!(process_instruction, 3))11,4009,976−12.5%3,203 → 3,202; 1,753 → 1,757

The bounded budget alone brings hello to within 216 bytes of Quasar’s 2,520 — with zero unsafe code, zero new macros, only the documented second argument of nostd_entrypoint!. The counter gains less because its .text is dominated by derive-generated dispatch and the RangeInclusive envelope triplication, not the deserializer; the symbol table shows its entrypoint fell 7,736 → ~1,256 bytes, but the freed budget re-surfaced as extra arms in the inlined router.

A second measurement isolated the router lever: outlining the router with #[inline(never)] (the stub-plus-dispatcher split Anchor v2’s 32-byte entrypoint uses) grew the counter to 11,800 bytes — LLVM merges more when the router inlines into one frame. The budget is the dominant lever; the router split is neutral-to-negative until the per-arm walking of the lean dispatcher exists.

Decision

  1. Adopt the account budget now (no framework change): document the second argument of nostd_entrypoint! in the program-size guide, and have pina init emit a program-specific bound — the program’s widest instruction’s account count plus headroom — instead of the 255 default.

    Amended 2026-09-29. A bound equal to the widest instruction is not safe: the loader skips accounts past the entrypoint’s array instead of rejecting them, so the budget = 1 and budget = 3 fixtures measured above no longer rejected an extra trailing account with TooManyAccountKeys — finish_exact never saw it. The bound must keep one spare slot. #[discriminator(entrypoint)] now generates it as ENTRYPOINT_ACCOUNT_CAPACITY (the widest routed accounts struct or reserved Migrate route, plus one, or 255 when a route accepts unbounded trailing accounts), and the program-size guide documents it. The mechanism is also narrower than this ADR’s context states: pinocchio walks accounts five at a time rather than unrolling one body per slot, so a bounded array only removes code when it has five slots or fewer, and a larger bound grows the program by the loop that skips accounts past it. With the spare slot and the seed-capacity change, the fixtures measure hello 4,680 → 2,944 and counter 10,456 → 9,416, at +1 compute unit on hello and initialize and +4 on increment.

  2. Do not build the lean dispatcher — it was prototyped and measured out. A working prototype dispatching through pinocchio’s public InstructionContext (lazy walk, duplicate mapping, exact-count views) measured 10,360 bytes against the budget lever’s 9,976 on the counter fixture: .text 7,936 vs 7,736 plus 48 more relocations. Two architectural facts close the question. First, the instruction data sits after the account region in the loader’s input, so any dispatcher must walk every account before it can read the discriminator — “parse only the matched arm’s accounts” is unreachable under the entrypoint ABI. Second, pinocchio’s compile-time-unrolled deserialize::<N> for a small bound is tighter than any general loop with duplicate handling; the lazy walk duplicates that work in a less specialized shape. The dispatcher layer was never the remaining gap: after the budget and slicing levers, the counter’s ~1,030 bytes of .text over Anchor v2 sit in pina’s semantic surface — the seed-and-signer assembly, the outlined address-comparison asserts, and the envelope plumbing — each individually load-bearing and each CU-cheaper than Anchor’s runtime equivalent.

    Amended 2026-09-30. ADR 0011 proposes reversing this decision. Its first premise no longer holds: since SIMD-0321 the loader passes a pointer to the instruction data at entry, so a dispatcher can read the discriminator before walking any account. The prototype there measured the counter fixture at 7,960 bytes with every check intact.

  3. Stop here and keep the CU lead. The counter at 9,976 bytes (−21.6% from stock) with initialize at 3,203 CU versus Anchor v2’s 8,696 bytes at 3,458 CU is the measured optimum for pina’s feature set. Going below Anchor’s byte count requires removing checks (an opt-out envelope mode, weaker derivation verification), which is a product decision with security trade-offs, not an engineering lever; this ADR records the measurement so the dispatcher is not re-attempted without new constraints.

The comparison fixtures keep publishing stock numbers; the pina_lean fixture remains as the measurement bed documenting the budget and slicing levers. Amended 2026-09-29: the published Pina fixtures now use the generated router with ENTRYPOINT_ACCOUNT_CAPACITY — the configuration this ADR recommends — and the pina_lean fixtures, which measured the unsafe bound, were removed.

Consequences

  • Deployed sizes landed at Quasar’s class neighborhood without losing checks. Measured: hello −41.5% (to 2,736, near Quasar’s 2,520); counter −21.6% from stock (12,720 → 9,976 with the budget and slicing levers shipped here). The remaining 1,280 bytes to Anchor v2’s 8,696 are the feature surface itemized in decision 2; the dispatcher prototype proved they are not recoverable by entrypoint architecture.
  • The CU lead is retained. The budget lever costs +6 CU on hello and +1/+4 CU on the counter; pina’s initialize stays under Anchor v2’s 3,458 and Quasar’s 3,488. Per-arm exact parsing can reduce CU further by skipping 255-slot framing entirely.
  • Two entrypoint paths exist until the default flips. Both must stay feature-complete; the migration-aware discriminator envelope checks keep their ordering (envelope → program ID → accounts) on both paths.
  • Programs with many remaining-accounts instructions (for example account-array sweeps) must keep the unrolled path or pass a large budget — the lean dispatcher’s per-arm walking must be measured on such a program before the flip (multisig first).
  • Anchor v2’s 1,880-byte hello is not reachable while pina keeps its discriminant model, and that trade is deliberate: Anchor’s hello checks nothing beyond the program ID; pina’s hello validates a program ID, a discriminator envelope, and a signer, and its CU numbers are the payoff (hello 145 CU vs Anchor’s 127, initialize 3,295 vs 3,458). The committed target is Quasar-class size with pina’s CU advantage intact, not Anchor’s absolute minimum.

Alternatives considered

  • Drop pinocchio entirely (Quasar’s route: solana-account-view + static syscalls). Rejected as a first step: Anchor v2 proves pinocchio the library is compatible with 32-byte entrypoints, and dropping it forfeits audited CPI/sysvar code and the AccountView semantics every pina program already targets. Revisit only if the lean dispatcher still leaves a gap.
  • Lower the default budget for everyone. Rejected: the budget is a program-level contract (more accounts than the bound are ignored, not rejected), so a global default would silently change behavior for programs that accept many remaining accounts. The bound must be chosen per program.
  • Only outline the router (#[inline(never)]), keep the 255-slot deserializer. Measured and rejected: it grew the counter fixture by 120 bytes; the budget and per-arm parsing are where the bytes are.
  • opt-level = "z"/"s" for size. Previously measured in the program-size guide: shrank .text slightly but grew deployed ELFs (alignment and section-layout side effects) and regressed CU; LTO with opt-level = 3 dominates.
  • Lazy dispatch through pinocchio’s InstructionContext (the lean dispatcher of decision 2’s first draft). Prototyped and measured: +384 bytes over the budget lever on the counter fixture. The data-after-accounts input layout forces a full account walk before the discriminator is readable, and the specialized unrolled walk beats a general loop. Also measured alongside it: the #[inline] hint on the router changes nothing (9,976 either way; LLVM already picks the same layout), and a shared cold ProgramError → u64 converter is table stakes — the exhaustive switch costs 864 bytes in every formulation and Anchor v2 carries the identical 864.
  • Unifying the seed-and-signer assemblies in the PDA-creation spine. Estimated at ~100–150 bytes across two private function variants; the double assembly is real but small, recorded here as the largest known remaining purely-mechanical lever if the trade ever becomes worth it.

ADR 0011: Dispatch-first entrypoint on the instruction-data pointer

Context

The size work that followed ADR 0010, recorded in the program-size guide, brought the comparison fixtures to:

FixturePinaQuasarAnchor v2
hello1,9842,5201,880
counter8,4247,8088,696

Every check the fixtures ran before still runs. The counter is now 272 bytes under Anchor v2 and 616 bytes over Quasar.

ADR 0010’s reason for rejecting a dispatcher no longer holds

ADR 0010 decision 2 rejected per-instruction account parsing because “the instruction data sits after the account region in the loader’s input, so any dispatcher must walk every account before it can read the discriminator”. That was true of the original entrypoint ABI. SIMD-0321 changed it: the loader now passes a pointer to the instruction data in r2, the entrypoint’s second argument. The data’s length is the u64 stored in the eight bytes before it, the program ID follows the data, and the pointer always lands inside the input region, even for empty data. The proposal applies it under every loader. The feature (5xXZc66h4UdB6Yq7FzdBxBiRAFMMScMLwHxk2QZDaNZL) is active on mainnet (since epoch 950), devnet (1034), and testnet (911).

Both frameworks that beat or match pina already build on it. Quasar’s generated entrypoint(ptr, instruction_data) slices the data from r2 and dispatches before touching an account. Anchor v2’s __anchor_dispatch(input, ix_data_ptr) does the same. Pinocchio 0.11.2 has no r2 entrypoint, so pina’s programs still run deserialize::<N> over every account before the router reads the discriminator. The ADR 0010 dispatcher prototype went through pinocchio’s InstructionContext, which also walks accounts first, so it measured the same constraint rather than a way around it.

Where the counter’s remaining bytes are

Line-level attribution of the counter fixture, before the address-check change, against Quasar’s, both built with the comparison profile:

CostPina bytesQuasar bytes
Account parsing (walk plus typed parse)~1,400~600
initialize (PDA check and account creation)~2,800~3,100
increment (PDA, owner, and discriminator checks)~760~540
Entry, dispatch, and ProgramError conversion~1,480~1,650

Account parsing is the largest difference, and the only one in Quasar’s favor worth an architectural change: pina is already smaller in initialize and in its entry code. Pina walks every account through pinocchio’s generic deserializer, including its duplicate-account path, then parses the typed struct from that slice. Quasar parses exactly the accounts of the routed instruction.

Measured prototype

A scratch copy of the counter fixture replaced nostd_entrypoint! with an entrypoint that:

  1. Reads the instruction data from r2 and the program ID after it.
  2. Runs the generated router’s parse_instruction, so the program-ID and discriminator checks are unchanged and still come first.
  3. Parses exactly the routed instruction’s account count from the input into a stack array, which is right for the counter because both of its routes are exact (decision 3 covers the other shapes). Fewer accounts fail with NotEnoughAccountKeys, more fail with TooManyAccountKeys, and duplicate markers copy the earlier view the way pinocchio does.
  4. Runs the unchanged derived TryFrom (writable, duplicate-mutable, and exact-count checks) and the unchanged process.
BuildCounter bytesinitialize CUincrement CU
Before the address-check change8,7123,0801,738
The same, with the dispatch-first entrypoint8,048——
After the address-check change8,6323,0731,738
The same, with the dispatch-first entrypoint7,9603,0591,728
With the arithmetic error conversion8,0563,0791,740
The same, with the dispatch-first entrypoint7,2483,0591,728
The same, re-verifying the counter with sha2567,3443,059376
Quasar7,8083,488330

The per-instruction walk replaces about 1,400 bytes with 536: 328 for the three-account route and 208 for the two-account route. Building the account structs from a fixed-size array instead of the cursor saved nothing (+8 bytes), because LLVM already folds the cursor once the slice length is a constant.

An arithmetic error conversion removes the rest of the gap. LLVM merges a program’s many constant errors into one value in front of solana_program_error’s 26-way comparison tree, so the counter carries about 900 bytes of it even though every error it returns is a constant. Computing the status from the enum’s tag instead, with the layout proven at compile time, measured 8,056 bytes on the counter and 13,160 bytes smaller across the examples, but it cost 1 to 8 compute units on 53 example instructions because LLVM hoists the now-cheap tag constants onto success paths. It is held until that trade-off is decided. Together with the dispatch-first entrypoint, the counter measures 7,248 bytes, 560 under Quasar.

Every build in the table ran in the comparison verifier, which executes each instruction in Mollusk and checks the account state it leaves.

Compute units

Instruction traces from Mollusk’s register tracing account for the rest of the compute-unit gap.

  • increment spends about 1,500 of its 1,738 compute units in sol_create_program_address, which load_pda_mut calls to re-derive the counter’s address from its stored bump. Quasar and Anchor v2 hash the same inputs with sol_sha256 and compare. With that one change and every pina check intact, the prototype’s increment measured 376.
  • Without the program-ID comparison and the loader’s two 32-byte address copies, the prototype’s increment measured 353 against Quasar’s 330. Its 106 instructions against Quasar’s 83 trace to four causes:
    • LLVM hoists error codes onto the success path (about 7).
    • The prototype’s hand-written conversion re-checked the result on the way out (about 8); the arithmetic conversion’s exit is three instructions.
    • Pina reads the duplicate marker, signer, writable, and executable bytes one at a time, where Quasar compares all four as one u32 (about 4).
    • Pina’s borrow guard marks the account borrowed and releases it (3), which ADR 0003 requires.
  • hello measured 132 with the dispatch-first entrypoint, against Quasar’s 115. Its success path is 32 instructions plus the 100-unit log call, and 16 of those compare the program ID with ID. Without that comparison it measured 117. Most of the last two instructions over Quasar are checks Quasar does not make: pina requires exactly one account and exactly one byte of instruction data, where Quasar accepts extra accounts and trailing data.

Decision

Accepted. Decisions 1 to 5 and 10 are implemented as described below, which refines the proposal in two places: the reserved Migrate route reads its accounts like an unbounded route while process_migrate enforces its slot limit (decision 3), and a program with many routes shares one copy of the walk (decision 10). The results record the fleet measurements and runtime checks the proposal required.

  1. Add a dispatch-first entrypoint for programs routed by #[discriminator(entrypoint)]. The generated router gains __dispatch, which takes the loader’s input and the instruction data, validates the program ID and discriminator exactly as process_instruction does, then parses only the routed accounts struct’s accounts. dispatch_entrypoint!(CounterInstruction) declares extern "C" fn entrypoint(input: *mut u8, instruction_data: *const u8) -> u64, reads the data and program ID through the r2 pointer, and installs the allocator and panic handler the way nostd_entrypoint! does.

  2. Pina owns the account walk. Private functions in pina::entry walk the serialized accounts into a [MaybeUninit<AccountView>; N] with the record layout, alignment, and duplicate handling of pinocchio’s deserialize. They also check that each duplicate index names an earlier slot before copying it, a cold check that pinocchio leaves to the runtime.

    • The entrypoint wraps the raw input in EntrypointInput, which only pina can create. Its parse methods hold the unsafe and take the input by value, so the generated router contains no unsafe block and cannot walk the input twice. A generated block would carry the program’s spans and trip a program’s unsafe_code lint, which #[allow] cannot override under forbid. A second walk after a handler resized an account would read the changed data length.
    • A property test serializes random account layouts, with duplicates and varied data lengths, and requires the walk to find the same views as deserialize and leave the input byte for byte as it does. It replaces the fuzz target this ADR proposed; the walk’s unit tests and an end-to-end dispatch_entrypoint! program also run under Miri.
  3. Route shapes decide the walk. ACCOUNT_BOUND is a declared count, not a walk length: it counts a #[pina(remaining)] slice as one slot and an Option field like a required one. #[derive(Accounts)] therefore declares ParseAccounts::ACCOUNT_LIMIT, the most accounts a struct accepts, and ACCOUNT_MINIMUM, the fewest. Hand-written parsers keep an unbounded limit and a zero minimum. The two model three shapes:

    • Exact. When the limit equals the minimum, the route parses a fixed-length array and rejects any other count before the derived checks run.
    • Optional. When trailing Option fields make them differ, the walk reads the accounts present, rejects more than the limit, and leaves a missing required account to the derived parse, so an omitted optional account keeps working.
    • Unbounded. A struct with a #[pina(remaining)] field, directly or through a nested account group, and a hand-written parser with no limit read the first ENTRYPOINT_ACCOUNT_CAPACITY accounts, which is the transaction maximum whenever such a route exists and is what nostd_entrypoint! hands them. The walk stops at the array’s end, since the entrypoint already has the data from r2, so pinocchio’s deserializer is not linked at all.
    • The reserved Migrate route is optional, not exact. run_optional treats a missing trailing slot as omitted, so a partial migration sends fewer accounts than the slot count. The route reads the accounts present like an unbounded route, and process_migrate reads only its slots and fails on an account past the last one.
    • Tests through the new entrypoint cover an omitted optional account and a partial migration.
  4. Error precedence stays deterministic, with one documented change. The program-ID and discriminator checks still run before any account is read. For an instruction that has both a wrong account count and a failing per-account check, the count error now wins, because the count is checked before the derived parse runs. Today the derived parse may report a per-account error first.

  5. Roll out per program. dispatch_entrypoint! ships alongside nostd_entrypoint!.

    • Every runtime pina tests on passes the r2 pointer: Mollusk, which the comparison verifier and tests/compute_units.rs use, and Surfpool, whose suites for every converted example pass on the new entrypoint. The TypeScript LiteSVM suite (codama/tests/litesvm) loads only role_registry_program and optional_accounts_program, which keep nostd_entrypoint!, so it does not run the new entrypoint yet.
    • The comparison fixtures and the counter, migrations, escrow, and staking_rewards examples use it. multisig_program keeps nostd_entrypoint!, because its twenty routes measured larger (see the results).
    • pina init generates a hand-written process_instruction, not the #[discriminator(entrypoint)] router, so it keeps nostd_entrypoint! until its template moves to the router.
  6. Supersede ADR 0010 decision 2 and the claim in its decision 3 that going below Anchor v2’s size requires removing checks. The counter already measures under Anchor v2 with every check intact.

  7. Re-verify existing PDAs with sha256. The stored-bump loaders of accounts the program already owns and has initialized (load_pda and load_pda_mut) compare the account’s address with sha256(seeds ‖ bump ‖ program_id ‖ "ProgramDerivedAddress") instead of calling sol_create_program_address.

    • What is skipped: the syscall is the same hash plus a check that the result is off the ed25519 curve.
    • Why it is sound for these loaders: every account a pina program initializes at a seed-derived address went through invoke_signed with those seeds, and the runtime only signs for an off-curve address, so the stored bump already produced a valid PDA. Matching the hash identifies the same account. The only address the skipped check would add is an on-curve address equal to the hash, whose private key no one can derive and which the program never created.
    • Creation: the builders’ pre-check before the create-account CPI uses the same hash. There the runtime repeats the curve check itself, because it only signs the allocation for an off-curve address; an on-curve bump fails with “Could not create program address with signer seeds”.
    • Where it does not apply: checks of a caller-supplied bump that no invoke_signed follows keep create_program_address, and the canonical-bump loaders (load_checked_pda and load_checked_pda_mut) keep try_find_program_address, because proving a bump is the highest valid one needs the curve check.
    • Anchor v2 and Quasar verify stored-bump PDAs this way.
    • The stored-bump loaders and the creation pre-check now do this. On the counter, increment measured 1,738 → 378 and initialize 3,073 → 1,713.
  8. Keep the program-ID check unless the maintainers decide otherwise. It costs 16 instructions per call. Its main protection is a clear error when the same bytecode runs at another address, since owner and PDA checks against ID already fail there. Making it opt-out is a product decision this ADR leaves open.

  9. Compare account header flags as one word where the derive knows them. When an accounts struct states whether a field must be a non-duplicate signer, writable, or non-executable, the parser can check all four header bytes with one u32 comparison, as Quasar does, instead of four byte reads.

  10. Share the walk in a router with more than two routes. An inlined walk is the faster one: a fixed-length route unrolls it and folds the checks its positions rule out. Every route carries its own copy, though, 150 to 300 bytes each. A router with more than two routes calls one out-of-line copy instead, which returns bool so its result stays in a register. The reserved Migrate route follows its program’s choice without counting toward it. On the counter fixture the shared walk measured +64 bytes and +35 compute units on increment; on the examples with seven or more routes it is what makes the new entrypoint smaller than nostd_entrypoint!.

Results

Measured against main after the sha256 loaders, the creation check, and the capacity fix had merged.

FixturePina beforePina afterQuasarAnchor v2
hello bytes1,9841,6162,5201,880
hello CU146136115127
counter bytes8,4247,5927,8088,696
counter initialize / increment CU1,713 / 3781,694 / 3603,488 / 3303,458 / 2,117

The hello fixture is now the smallest of the four frameworks, and the counter is 216 bytes under Quasar with every check pina made before.

Programnostd_entrypoint!dispatch_entrypoint!Change
hello comparison fixture1,9841,616−368
counter comparison fixture8,4247,592−832
counter_program13,19212,984−208
migrations_program39,51237,952−1,560
escrow_program41,91238,912−3,000
staking_rewards_program52,50450,192−2,312

Across the fleet, 13 programs got smaller (−16 to −3,000 bytes) and two larger: transfer_sol_program (+40) and profile_program (+16), both on nostd_entrypoint! and changed only by the inlined account cursor. 61 instructions got cheaper, most by 30 to 170 compute units. The increases are:

  • migrations_program/update, +34 (+28 for a historical payload). Its struct has six optional accounts, and in the dispatch-first arm LLVM keeps more of their cursor reads out of line. Always inlining AccountsCursor::next_opt and next_mut_opt brought update to 280 (120 historical) but grew migrations_program by 680 bytes, so they stay as they are.
  • pina_bpf_program/createPda and todo_program/initialize +4, compact_accounts_program/rename +2, and three instructions +1, all on nostd_entrypoint! and all from the inlined cursor, without which the counter measured 800 bytes larger and its instructions 40 compute units dearer.

Consequences

  • The counter is below Quasar with pina’s checks. It measures 7,592 bytes against Quasar’s 7,808, without the arithmetic error conversion, and the hello fixture is the smallest of the four frameworks.
  • increment approaches Quasar’s compute units. It measures 360 against Quasar’s 330. The rest of the gap is the hoisted error codes, the byte-wise header checks, and pina’s borrow guard.
  • Instructions touch fewer accounts. Only the routed instruction’s accounts are walked, and no 255-slot array is framed.
  • Pina maintains an account walk. Walking serialized input is the trust boundary pinocchio’s deserializer crossed, now in pina’s code. Unit tests over loader-format input, Miri, and the property test against deserialize guard it.
  • Hard dependency on SIMD-0321. A program built with the new entrypoint reads an undefined r2 on a runtime without the feature. Every public cluster, Surfpool, and Mollusk have it.
  • Two entrypoint paths coexist. Both keep identical validation, and the macro tests expand both.
  • A large router can grow. Each route parses a fixed-length struct, which folds its checks but stops LLVM merging the routes’ identical parsing code. multisig_program stays on nostd_entrypoint! for that reason.
  • The account cursor inlines everywhere. AccountsCursor::next and next_mut are #[inline(always)], without which the counter measured 8,392 bytes and 40 compute units more. It also changes programs on nostd_entrypoint!, mostly for the better (see the results).

Alternatives considered

  • Keep pinocchio’s walk with a bounded array (ADR 0010, as amended). This is what ships today. It only removes code when the bound is five or fewer, and the counter stops at 8,632 bytes.
  • Walk lazily through pinocchio’s InstructionContext. Measured in ADR 0010 at +384 bytes over the bounded array, because it still walks every account before the data.
  • Build account structs from fixed arrays in the derive. Measured at +8 bytes: LLVM already folds the cursor for constant-length slices.
  • Fold the error conversion by inlining. Folding only happens when LLVM threads every constant error to its own status, which it stops doing once a program has many error sites. The arithmetic conversion reads the ProgramError tag through a layout proven by const-evaluated assertions over every variant, then computes (tag + 1) << 32. Reading the tag by value instead of through a pointer produced identical binaries.
  • Wait for pinocchio to ship an r2 entrypoint. Pina would drop its own walk and adopt pinocchio’s if one appears. Nothing in pinocchio 0.11.2 suggests it is imminent, and the size gap is measurable now.
  • Inline the walk in every route. The fastest code, but before the leading walk replaced pinocchio’s deserializer it grew escrow_program by 1,560 bytes, staking_rewards_program by 2,536, and multisig_program by 4,488 over nostd_entrypoint!.
  • Share the walk in every program. The smallest counter_program, but the hello fixture grew 400 bytes and 13 compute units, and the counter fixture 64 bytes and 22 to 35 compute units.
  • Walk bounded instead of exact in routers that share the walk, so routes keep a variable-length slice whose parsing LLVM can merge. It measured 448 to 768 bytes larger on every example with seven or more routes: the fixed-length fold is worth more than the merge.
  • Drop the exact account-count and data-length checks. They are the last two hello instructions over Quasar. Rejecting extra accounts and trailing instruction data is part of pina’s validation contract, so this ADR keeps them.

ADR 0012: Judge instruction process compatibility on wire facts only

Context

pina migrations create snapshots each instruction’s positional account list as a ProcessContract. Every ProcessAccount slot records its name, its writable, signer, and optional flags, and two more fields: defaultValue, the known address generated clients fill in, and pda, the Pina PDA the slot belongs to.

The document already says that only wire facts belong in the snapshot, but compatibility compared every field. classify_process_transition, the create path, and the drift check behind pina migrations check, status, and IDL generation all used whole-slot equality. So a change that only affects what generated clients fill in looked like a wire break.

The trigger was better PDA detection in the IDL extractor. Typed loads and helper functions now reveal PDAs it missed before, so existing slots gain a pda. For an unpublished draft, create just re-records it. A published instruction refuses any change to an existing slot, so upgrading pina_cli would leave such a program unable to pass pina migrations check or generate its IDL, although its program and every client request are unchanged.

defaultValue and pda do not change what the program accepts. A client that passes an account explicitly sends the same list whether or not the IDL could have derived it. The wire facts are name, writable, signer, and optional: they decide whether an old account list still parses and is authorised. (Renaming a slot is treated as breaking because the name is the slot’s stable identity in the snapshot.)

Decision

Process compatibility compares wire facts only.

  • ProcessAccount::same_wire compares name, writable, signer, and optional. ProcessContract::same_wire requires the same slots in the same order. Both ignore defaultValue and pda.
  • classify_process_transition returns Unchanged when the processes are equal on the wire, and AppendOptional when the destination’s leading slots are equal on the wire and every appended slot is optional. ProcessTransitionKind::Unchanged now means “unchanged on the wire”, not byte-for-byte.
  • pina migrations create, check, status, and IDL generation compare the recorded snapshot with source the same way, through CurrentContract::matches_wire. A hint-only difference is not drift, consumes no version, and does not rewrite the manifest.
  • A hint-only difference keeps the recorded snapshot’s hints. Hints are carried forward unchanged until a wire change rewrites an unpublished draft or appends a version; that write records the source’s current hints along with its wire facts.

The document format does not change: the same fields are written the same way, and no stored or pinned value moves.

Consequences

  • Upgrading pina_cli with better PDA or known-address detection never blocks a published instruction, and never forces a re-recording of one.
  • A published snapshot’s hints can lag the source. That is harmless, because nothing reads them for compatibility or client generation: clients are generated from the current IDL, not the manifest. The recorded hints document what the clients of that version filled in.
  • ProcessContract::sha256 still hashes the whole recorded document, hints included. It identifies a document, not a compatibility class. No receipt pins a process hash: a publication receipt pins schemaSha256, which covers only the payload schema, and transitionSha256, which covers only transition source. Neither can move when a hint changes, so a hint-only change cannot cause a hash mismatch. The checked-in fixtures under crates/pina_abi/fixtures and every existing document hash stay byte-identical.
  • The ProcessAccount doc comments are emitted into the published JSON Schemas, which are frozen per abiVersion. The rule is documented on same_wire and here instead of in those comments.
  • A name, signer, writable, or optional change, a reorder, a removal, or a new required slot still fails closed and still needs a new instruction discriminator.

Alternatives considered

  • Re-record hints on a published version in place. Process hashes are not pinned, so create could rewrite a published snapshot’s hints without breaking a receipt. It was rejected because it rewrites a published version whenever detection improves, which churns the manifest and the generated tests/abi_layout.rs header for no wire reason, and because a snapshot that silently changes after publication is harder to audit.
  • Append a new version for a hint-only change. That spends a version number, and for an enveloped instruction forces a no-op transition, to record a fact the program does not depend on.
  • Remove defaultValue and pda from the snapshot. That is a document-format change: it needs a new abiVersion, a converter, and new schemas, and it drops a useful record of what each version’s clients filled in. Ignoring the hints when comparing gets the compatibility fix without a format change.
  • Keep comparing every field and tell users to re-record. A published version cannot be re-recorded, so this leaves the program stuck.

Crates and Features

PackagePathDescription
pinacrates/pinaCore framework: traits, account loaders, CPI helpers, and Pod types.
pina_macroscrates/pina_macrosProc macros: #[account], #[instruction], #[event], and others.
pina_clicrates/pina_cliCLI for building, testing, inspecting, and generating Pina program artifacts.
pina_codama_renderercrates/pina_codama_rendererRepository-local Codama Rust renderer for Pina-style clients.
pina_cpi_renderercrates/pina_cpi_rendererStandalone Codama renderer generating Pina CPI client crates.
pina_lintscrates/pina_lintsPina security lints and the driver behind pina lint.
pina_testcrates/pina_testSurfpool-backed program test harness.
pina_profilecrates/pina_profileStatic and trace-driven CU profiler for SBF programs.
pina_sdk_idscrates/pina_sdk_idsTyped constants for well-known Solana program/sysvar IDs.
@pina-rs/codama-nodespackages/nodes-from-pinaPina IDL conversion and normalization for Codama root nodes.
@pina-rs/clipackages/pina__clinpm launcher for the prebuilt platform-specific CLI packages.
@pina-rs/skillpackages/pina__skillAgent guidance and a non-destructive local skill installer.

crates/pina

Core runtime crate for on-chain program logic.

Includes:

  • AccountView and validation chain helpers.
  • Typed account loaders and discriminator checks.
  • CPI/system/token helper utilities.
  • nostd_entrypoint!, dispatch_entrypoint!, and instruction parsing helpers.
  • Instruction introspection (program-ID checks, sandwich detection).
  • Pod types with full arithmetic operator support.

Feature flags:

FeatureDefaultDescription
deriveYesEnables proc macros (#[account], #[instruction], etc.)
logsYesEnables static on-chain logging via solana-program-log
verbose-logsNoAdds formatted diagnostics and caller locations
compactNoEnables compact schemas, checked loaders, and typed APIs
floatsNoEnables f32/f64 schema fields
fixedNoEnables fixed-point FixedI*/FixedU* schema fields
validationNoEnables declarative, allocation-free application validation
tokenNoEnables SPL token / token-2022 helpers and ATA utilities
memoNoEnables memo program helpers via pina::memo
account-resizeNoEnables raw account reallocation and safe Pinocchio resizing

Feature selection tips

  • derive is the normal choice for program crates; disable it only when you want the low-level runtime traits without the proc macros.
  • compact enables #[account(compact)], PinaCompactAccount, generated patch types, checked compact loaders, and pina::String and pina::Vec. It also enables derive.
  • floats enables IEEE-754 f32 and f64 schema fields, stored as their bit pattern through the pina::PodF32 and pina::PodF64 pods from pinapod. It also enables derive.
  • fixed enables fixed-point FixedI*<Frac> and FixedU*<Frac> schema fields from the pinned fixed crate, which Pina re-exports as pina::fixed. It also enables derive.
  • validation enables PinaValidate and #[pina(validate(...))] rules on accounts, instructions, events, and derived account lists. It also enables derive.
  • logs is useful during initial development and debugging, testing, and audits. It logs a fixed message on each failure path without linking core::fmt. Disable it when you want the smallest possible binary or completely silent runtime failures.
  • verbose-logs adds formatted diagnostics and file:line:column caller locations to failure paths. Formatting pulls core::fmt into the deployed binary — roughly 2 KB on a hello world and 12 KB on a counter — so treat it as a debugging feature and leave it off for production deploys. See Program size.
  • token enables pina::token, pina::token_2022, pina::associated_token_account, and the TokenAccount compatibility aliases over the upstream renamed account types.
  • memo is separate from token, so memo CPI support can be enabled without pulling in the token helper surface.
  • account-resize enables ReallocAccount and ReallocAccountZeroed. Enable it together with compact for UpdateResizableAccount, ReallocCompactAccount, and the compact creation builders. 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:

  • pina init <name>: scaffold a project-aware Pina program
  • pina build: build SBF and publish the program IDL
  • pina generate: generate configured CPI, Rust, TypeScript, or Dart clients
  • pina test [--unit]: run native/Mollusk or SBF/Surfpool tests
  • pina dev [--yes]: run Surfpool’s persistent watch/redeploy loop
  • pina verify: compare deployments and record verified source
  • pina idl --path <dir>: generate a Codama IDL JSON from a Pina program
  • pina docs [topic]: list or render bundled terminal documentation
  • pina keys [show|sync|new]: inspect or explicitly update program identity
  • pina doctor [--json]: diagnose project and toolchain readiness
  • pina completions <shell>: generate a shell completion script
  • pina profile [path.so]: profile a compiled or discovered SBF binary statically
  • pina profile trace: measure executed compute units line by line from Mollusk tests
  • pina deploy: plan and execute an explicit cluster deployment

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)

Generated program feature flags:

FeatureDefaultDescription
bpf-entrypointNoCompiles the on-chain entrypoint for SBF deployment builds.

CPI clients are standalone generated crates rather than a feature of the deployed program crate. Add cpi to clients.languages in pina.toml, then run pina generate:

[clients]
output = "clients"
languages = ["cpi", "rust", "typescript"]
mode = "auto"
scaffold = true

The consuming program depends on the generated crate directly. This avoids coupling a program’s deployable feature graph to downstream CPI consumers. Auto updates preserve customized manifests and entrypoints; set scaffold = false for generated sources only or mode = "overwrite" for an explicit clean sweep.

Pod types

The pina::pod module re-exports PinaPod’s alignment-safe POD primitive wrappers (PodBool, PodU*, PodI*), the IEEE-754 PodF32 and PodF64 (with the floats feature), 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.

TypePurposeLayout
PodOptionFixed-size Option<T>1-byte discriminant + T
PodStringFixed-capacity stringPFX-byte length prefix + N data bytes
PodVecFixed-capacity vecPFX-byte length prefix + N elements

The full generic forms are PodOption<T: ZcElem, PFX = 1>, PodString<N, PFX = 1>, and PodVec<T, N, PFX = 2>. PFX is the prefix width in bytes and must be 1, 2, 4, or 8. Strings default to one byte and vectors default to two bytes. ZcValidate checks tags, prefixes, active elements, and UTF-8 before safe access.

Fixed account, instruction, and event schemas can use String<N>, Vec<T, N>, and Option<T> when every nested T has a fixed PinaPod representation. These values occupy their full capacity in the wire layout. PinaPod initializes inactive capacity, clears removed values, and validates active nested values before safe access.

Use PodString<N, PFX> and PodVec<T, N, PFX> when the default prefix width does not fit the declared capacity or the wire protocol specifies another width. The const generic is explicit: write PodVec<u64, 1024, 2>, not a macro attribute that selects u16.

Compact accounts store supported top-level strings, vectors, and dynamic options in tails, so unused capacity does not consume rent. See the compact-account guide for the accepted nesting forms and atomic patch API.

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
pina profile compare r.json                        # diff against a saved baseline

The profiler decodes each SBF instruction opcode and assigns costs: regular instructions cost 1 CU, syscalls cost 100 CU. pina profile compare diffs the current artifact against a saved report and exits 2 when the total CU regression reaches both --fail-cu (default 500) and --fail-percent (default 10), mirroring the CI compute-unit gate.

pina profile trace measures instead of estimating. It builds the program with DWARF line tables, runs its Mollusk tests with register tracing, and attributes every executed instruction to a source line and call stack:

pina profile trace                          # summary plus an HTML report
pina profile trace --instruction increment  # one instruction
pina profile trace --folded > stacks.folded # flame graph input

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 helpers
  • instructions.rs — instruction page, account metas
  • types.rs — Pod type rendering, defined types
  • errors.rs — error page rendering
  • discriminator.rs — discriminator rendering
  • seeds.rs — seed parameter/constant rendering

Use this when you want generated Rust models to match Pina’s discriminator-first, PinaPod-validated conventions for fixed and compact accounts.

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:

  1. Generate Codama JSON from Rust programs (pina idl).
  2. Validate generated JSON against committed fixtures/tests.
  3. Render clients (JS and Dart with Codama renderers, Rust with pina_codama_renderer, and standalone CPI crates with pina_cpi_renderer).

In This Repository

Generate and validate the whole workspace flow with devenv scripts:

# Generate IDLs and clients for all examples.
codama:idl:all

# Generate Rust + CPI + JS + Dart clients.
codama:clients:generate

# Generate one project's configured clients.
pina generate

# Run the complete generation and validation 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-pina-clients.sh: regenerates codama/idls/*.json fixtures and every client family for all examples by driving pina generate once per project. Each example’s pina.toml owns its IDL and client output paths.
  • scripts/verify-pina-clients.sh: regenerates IDLs/clients, verifies fixtures and generated clients with Rust, JS, and Dart tests, and enforces deterministic no-diff output (including untracked files).

The generated Dart package lives in codama/clients/dart. It exposes one package-root library per example, pins dependency resolution in pubspec.lock, and checks all 26 example IDLs as a single inventory. CI runs dart format, dart analyze --fatal-infos, and dart test over the checked-in output.

Generation is driven entirely by the Pina CLI. pina generate discovers a project through its pina.toml, refreshes its IDL, and renders only the configured client ecosystems, which keeps the repository dogfooding the same command that users run. Generating a whole repository means running the command once per project.

For project-aware generation, [clients] in pina.toml controls mode (auto, create, update, or overwrite) and scaffold, with optional overrides under [clients.cpi], [clients.rust], [clients.typescript], and [clients.dart]. Dart is also the Flutter target. Update mode replaces only generated sources, so user-owned manifests and entrypoints can be customized without being rewritten. See Project Configuration for the complete schema.

Compute unit budgets

Every example commits a compute-units.json recorded by pina test --record-compute-units: the most compute units each instruction consumed in a successful Surfpool simulation. IDL generation attaches each measurement and the limit derived from it as a pinaComputeUnits plugin node on the instruction ({ "measured": 379, "limit": 800 }). Codama’s validators and the upstream JavaScript and Dart renderers carry plugin nodes through untouched, so the IDLs stay standard Codama.

The Rust renderer turns each plugin into <NAME>_MEASURED_COMPUTE_UNITS and <NAME>_COMPUTE_UNIT_LIMIT constants plus a crate-level set_compute_unit_limit_instruction. Pina’s post-processing adds the same constants and a get<Program>ComputeUnitLimit(instructions) helper to the TypeScript and Dart clients, and the generated CLIs request each instruction’s limit automatically. An instruction no successful test sent has no measurement, and its clients request the runtime default. See compute unit limits for the formula and the staleness warning.

After changing an example, record again before regenerating so its limits describe the current program:

pina test --record-compute-units --project examples/counter_program
pina generate --project examples/counter_program --npx node

Solana Kit dependencies

Pina pins codama-renderers-dart@0.5.6, which includes upstream support for pre/post-offset collection length codecs, and uses Solana Kit Dart packages at ">=0.10.0 <1.0.0" — the range the generated sources are verified against. This preserves shared compact headers with multiple dynamic tails in addition to schema normalization, package exports, discriminator enforcement, exact instruction decoding, capacity-aware account decoding, fixed-capacity overflow rejection, canonical boolean, option, and UTF-8 codecs, and wide-enum support. No renderer patch or renderer-specific dependency override is required.

The generated-client contract suite verifies those behaviors directly. Dependency upgrades must continue to pass the same byte-level contracts without patches, Git overrides, or generated-source rewrites.

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"));

Generated clients are an untrusted boundary. The checked-in contract suite requires encoders to reject fixed and compact capacity overflow. Decoders enforce discriminators, declared capacities, canonical boolean and option tags, and strict UTF-8. They do not enforce exact top-level lengths everywhere: TypeScript account and instruction decoders accept trailing bytes, and Dart account decoders do too, while the on-chain try_from_bytes requires an exact length. A renderer version that cannot satisfy those contracts is rejected instead of producing a client with a different wire format.

Decoding is not attribution. No generated decoder checks which program owns an account, so compare the fetched account’s owner with the program address before trusting decoded state, or fetch a PDA through its generated fetch…FromSeeds helper. Program-level event parsers (parse<Program>EventsFromLogs in TypeScript and Dart) take a transaction’s complete, ordered logs and decode a Program data: line only while the program is the innermost invocation, because any other program, including one it calls through CPI, can log bytes that start with the same discriminator. The per-event parse<Event>FromLog helpers decode a single line without that attribution. Each earlier version of a versioned event is its own <Event>V<n> event node in the IDL with its own codec; the program-level parser routes every record to the event for its discriminator and version, and throws on a version no generated event describes instead of misreading it.

3. Generate Dart clients with Codama

pina generate renders the same IDLs into the checked-in Dart package at codama/clients/dart. Each program has a package-root entrypoint, for example:

import 'package:pina_codama_clients/profile_program.dart';

Run codama:test to resolve the committed lockfile, format the generated Dart, analyze it with fatal infos, and execute the byte-level contract tests.

4. Generate Pina-style Rust clients (optional)

This repository ships crates/pina_codama_renderer, which emits Rust models aligned with Pina’s discriminator-first PinaPod layouts.

cargo run --manifest-path ./crates/pina_codama_renderer/Cargo.toml -- \
  --idl ./idls/my_program.json \
  --output ./clients/rust \
  --mode auto

You can pass multiple --idl flags or --idl-dir. Add --no-scaffold to emit only src/generated; use --mode overwrite for an intentional clean regeneration.

Renderer constraints

pina_codama_renderer supports fixed layouts and Pina’s bounded compact-account grammar. It rejects unbounded collections, unsupported dynamic nesting, unsupported endian or number forms, and ambiguous layouts.

Extractor coverage

The extractor currently supports these dispatch shapes:

  • Generated dispatch: an #[discriminator(entrypoint)] enum, whose variants route to VariantAccounts unless #[dispatch(accounts = OtherAccounts)] overrides them
  • Canonical routed arms: Variant => Accounts::try_from((program_id, accounts))?.process(data)
  • Grouped routed arms: VariantA | VariantB => SharedAccounts::try_from((program_id, accounts))?.process(data)
  • Versioned routed arms: Variant => Instruction::process_versioned(Accounts::try_from((program_id, accounts))?, data), the form a hand-written dispatcher uses for an instruction that keeps its migration envelope
  • Accountless arms: Variant => { let _ = Payload::try_from_bytes(data)?; Ok(()) }
  • Accountless entrypoint fallback: if a single process_instruction exists but has no recognizable dispatch map, Pina emits zero-account instruction nodes from the declared payload structs.

Keep in mind:

  • Account metadata is inferred from the Accounts::try_from((program_id, accounts)) conversion an arm performs. The conversion is read wherever the arm performs it, including when it is bound to a local first. An arm that converts into two different structs has no single account layout and is emitted without accounts.
  • Signer, writable, and known default-account metadata can be declared with #[pina(validate(...))] on #[derive(Accounts)] fields.
  • The process body is read too: direct assert_signer(), assert_writable(), and assert_address() chains, the PDA validators and loaders #[pda] generates (assert_seeds, assert_stored_bump, load_pda, load_checked_pda, with_stored_bump_pda, with_checked_pda, and their mutable forms), PDA creation builders, and typed loads such as as_account::<T>() and with_compact_account::<T, _>(). Writable inference also comes from mutable fields such as &'a mut AccountView.
  • A module-level helper function the process body passes an account field to is analysed as part of that body, up to four calls deep. Methods, associated functions, and helpers whose name is declared more than once are not followed.
  • A field loaded as a #[pda] account type belongs to that PDA. Generated clients derive its default address only when the processor pins the address: it validates the account against its seeds, or every seed of the PDA is a constant. A field inferred as a PDA must resolve to a declared #[pda]; generation fails instead of emitting an incomplete link.
  • If you hide routing or validation behind method calls or closures, instruction nodes may still exist, but account metadata becomes less complete.
  • Multiple files containing process_instruction or an #[discriminator(entrypoint)] enum, malformed or unresolved #[pda] attributes, missing package names, and missing unconditional modules are rejected as ambiguous or incomplete inputs.

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((program_id, accounts))?.process(data)
			}
			MyInstruction::Update => {
				UpdateAccounts::try_from((program_id, accounts))?.process(data)
			}
		}
	}
}
}

Grouped dispatch with shared accounts

#![allow(unused)]
fn main() {
match ix {
	MyInstruction::Initialize => InitializeAccounts::try_from((program_id, accounts))?.process(data),
	MyInstruction::Toggle | MyInstruction::Update => {
		UpdateAccounts::try_from((program_id, 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((program_id, 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(())
	}
}
}

The equivalent client-visible constraints can be declared next to the fields:

#![allow(unused)]
fn main() {
#[derive(Accounts)]
pub struct InitializeAccounts<'a> {
	#[pina(validate(signer))]
	pub authority: &'a AccountView,

	#[pina(validate(writable))]
	pub state: &'a AccountView,

	#[pina(validate(program = system::ID))]
	pub system_program: &'a AccountView,
}
}

Value bounds, owners, data lengths, relationships, and custom hooks remain runtime-only because Codama does not represent them as instruction account metadata.

PDA seed helpers

#![allow(unused)]
fn main() {
const SEED_MY: &[u8] = b"my";

#[macro_export]
macro_rules! my_seeds {
	($authority:expr) => {
		&[SEED_MY, $authority]
	};
	($authority:expr, $bump:expr) => {
		&[SEED_MY, $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::Initialize)]
pub struct InitializeInstruction {
	pub bump: u8,
}

#[instruction(discriminator = MyInstruction::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 and the codama/clients tree with pina generate
  • generated JSON passes Codama’s JS validator
  • generated JS clients typecheck
  • generated Rust, CPI, and Rust CLI clients compile, and the Rust CLI crates pass their tests
  • generated Dart clients resolve with the lockfile, format cleanly, pass static analysis, and pass codec contract tests
  • 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 focused usage patterns. They are not audited applications or deployment templates:

  • hello_solana_program: 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_program: lamport transfers and account checks.
  • escrow_program: richer multi-account flow and token-oriented logic.
  • vesting_program: schedule-state and vault-ATA scaffold; it does not enforce time-based vesting or transfer tokens.
  • role_registry_program: role-based configuration and registry PDAs with admin rotation.
  • staking_rewards_program: staking account and bookkeeping scaffold; deposit, withdraw, and claim do not transfer tokens.
  • profile_program: user profile registry with fully initialized bounded UTF-8 and tag fields plus checked semantic accessors.
  • pina_bpf_program: minimal pina-native BPF hello world with nightly build-std=core,alloc.
  • prop_amm_program: Pina-native semantic port of Anchor anchor-next benchmark prop-amm, focused on authority-controlled oracle updates without the upstream asm fast path.
  • declare_id_program: first Anchor test parity port, focused on program-id mismatch checks.
  • declare_program: Anchor declare-program parity for external-program ID checks.
  • duplicate_mutable_accounts_program: explicit duplicate mutable account validation pattern.
  • custom_errors_program: Anchor-style custom error code and guard helper parity.
  • events_program: event schema parity through deterministic serialization checks.
  • float_accounts_program: float data account create/update flow with authority validation.
  • system_accounts_program: system-program owner validation parity.
  • sysvar_checks_program: clock/rent/stake-history sysvar validation parity.
  • account_realloc_program: authority-bound PDA realloc lifecycle, growth limits, and duplicate-target safety checks.
  • compact_accounts_program: generated compact patches across create, grow, same-size update, shrink, clear, rent adjustment, and rejected boundary cases.
  • optional_accounts_program: optional-account slots with explicit presence handling and count tracking.
  • heap_alloc_program: opt-in heap allocation through nostd_entrypoint_alloc!, with the bump allocator, heap-frame sizing, and allocation-failure behavior.
  • validation_program: end-to-end declarative validation for instructions, instruction accounts, stored account state, and events.
  • multisig_program: production-shaped multisig consensus — bitmask voting, timelocked vault execution of compiled messages, governed config action streams, spending limits, legacy-account import, and the migration envelope.

Use examples as references for the specific framework behavior each one demonstrates. Do not infer unimplemented economic behavior from an instruction name. Read Production Readiness before adapting an example for an asset-bearing program.

Anchor test-suite parity progress is tracked in Anchor Test Porting.

Every example directory includes a local readme.md with purpose, coverage, limitations, and run commands. Some E2E suites skip when their SBF binary is missing; production CI should build the artifact first and treat a missing artifact as a failure.

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.

Compute-unit performance

Pina tracks four performance signals on every pull request:

  • The ignored Surfpool suite records compute units for every exercised example instruction against copied base and head ELFs. Focused Mollusk fixtures cover security-sensitive behavior changes that need exact outcome checks.
  • pina profile records static whole-program estimates, binary size, text size, and syscall counts for every top-level example program.
  • Hyperfine compares representative base and head CLI commands.
  • The existing host benchmark suite compares medians for performance-sensitive core operations.

The comparison uses base - head. A positive score is an improvement because the head consumes fewer compute units. A negative score is a regression.

The jobs run in parallel and update one sticky pull-request comment. Each section keeps its emoji summary visible and collapses the full table in a details block. CLI and host timings are advisory because hosted-runner timing is noisy. Instruction-CU regressions enforce the configured policy.

PinaPod v0.2 migration results

The following exact results compare Pina v0.14.0 (eeaeb1ec) with the PinaPod v0.2 migration. Both sides use Solana’s cargo build-sbf, the pinned nightly-2025-11-20 toolchain, Mollusk 0.14.0, identical instruction bytes, and identical account fixtures.

Instruction caseBase CUHead CUPerformance changeChange
account_realloc_program/grow_0_to_88,3786,749+1,629+19.4%
account_realloc_program/initialize9,7589,782-24-0.2%
account_realloc_program/rewrite_8_to_86,9635,335+1,628+23.4%
account_realloc_program/shrink_8_to_07,1105,508+1,602+22.5%
counter_program/increment2,1841,958+226+10.3%
counter_program/initialize14,29814,198+100+0.7%
profile_program/add_tag2,2672,271-4-0.2%
profile_program/initialize7,2247,392-168-2.3%
profile_program/remove_tag2,2962,276+20+0.9%
profile_program/update_profile2,5762,554+22+0.9%

Seven of ten instruction cases improve. Compact realloc operations improve by 19.4% to 23.4% after removing duplicate account and PDA validation. The generated load_pda_mut helper makes the counter mutation 10.3% faster and prevents recursively validating a fixed representation several times at one boundary.

Three reviewed increases remained in that historical comparison. Compact initialization added 24 CU, fixed profile tag insertion added 4 CU, and fixed profile creation added 168 CU. Profile creation deliberately validated the completed String, Vec, and Option representation after its initializer ran. That final check prevented a closure from committing malformed state.

Token loader consolidation results

The focused token-loader fixture compares the former unsuffixed API with the consolidated checked API. Mint and ordinary token-account loading remain exactly unchanged because both versions use the same checked upstream account-view parsers. Canonical ATA loading pays for two additional stored-state comparisons:

Instruction caseBase CUHead CUPerformance changeOutcome
Legacy mint, success8484+0success -> success
Legacy token account, success8585+0success -> success
Token-2022 mint, success8989+0success -> success
Token-2022 token account, success8888+0success -> success
Explicit owner assertion, then load112112+0success -> success
Legacy mint, wrong owner7676+0rejected -> rejected
Legacy token account, wrong owner7676+0rejected -> rejected
Token-2022 mint, wrong owner7676+0rejected -> rejected
Token-2022 token account, wrong owner7575+0rejected -> rejected
Legacy canonical ATA, success7,6897,723-34success -> success
Token-2022 canonical ATA, success3,1903,224-34success -> success
Legacy ATA, wrong address7,6367,638-2rejected -> rejected
Legacy ATA, reassigned authority7,6897,704-15success -> rejected

The 34-CU ATA increase is 0.44% for legacy Token and 1.07% for Token-2022. It buys validation that the stored mint and current token authority agree with the inputs used to derive the ATA address. The reassigned-authority case is a deliberate semantic change, so CI labels it as a behavior change instead of claiming that the earlier rejection is a performance improvement. The three unchanged-outcome ATA increases were approved for that migration. Those historical allowances have since been retired so future pull requests preserve the improved baseline.

Static SBF profile results

The broader static profile agrees with the instruction-level measurements. Unaffected examples remain byte-for-byte stable, while every example changed by the account migration has a lower whole-program CU estimate.

ProgramBase CUHead CUPerformance changeChange
hello_solana_program837837+0+0.0%
duplicate_mutable_accounts_program1,0041,004+0+0.0%
events636636+0+0.0%
sysvar_checks_program1,6621,662+0+0.0%
system_accounts_program1,1301,130+0+0.0%
account_realloc_program5,1354,787+348+6.8%
compact_accounts_program6,8776,445+432+6.3%
counter_program4,0793,056+1,023+25.1%
profile_program4,9303,588+1,342+27.2%

The four changed binaries also shrink: account_realloc_program by 2,872 bytes, compact_accounts_program by 3,560 bytes, counter_program by 8,584 bytes, and profile_program by 11,464 bytes. These are historical migration results; the pull-request report now measures all examples automatically.

Pull request policy

Instruction cases with the same result fail on every unapproved increase. A reviewed redesign can add an exception to runtimeCuApprovals, recording the full PR baseRevision, measured base, approved maximum head, and a reason. It applies only to that base commit and measured value, then expires when the base changes. Binary growth is independently gated through binarySizeApprovals; a CU exception does not permit size growth. See CI and releases for the exception format. When base and head have different success outcomes, the report labels the case as a behavior change rather than comparing unlike execution paths as a speedup or regression. Security-sensitive cases also declare their required head outcome in runtimeExpectedOutcomes, so an accidental rejection-to-success change fails CI.

Static profiles warn when both the absolute and percentage warning thresholds are reached, and fail when both failure thresholds are reached. Smaller static increases stay visible as regressions. Programs and instructions are discovered from the head checkout. Head-only items create baselines; missing head measurements fail.

A Surfpool batch that exits nonzero fails the head measurement, with one exception: when every test binary in the batch printed test result: ok and every instruction case the batch owns has at least one sample, the exit came from the Surfpool runtime shutting down after the measurements landed. The report records that batch under incompleteTeardowns and lists it as a tolerated teardown exit instead of failing. A failed test, a binary that died before its summary, or a case without samples still fails.

The workflow uploads the reports, manifests, copied ELF files, and SHA-256 hashes as CI artifacts. This provenance prevents a successful report from silently describing a stale or different binary.

Run the comparison locally

Run the complete base-versus-head comparison from the repository root:

devenv shell -- report:cu:compare:main

Run only the current all-example static profile set:

devenv shell -- profile:cu:tracked

Reports are written under target/cu/. The Markdown report is intended for review; the JSON report is the machine-readable source for later analysis.

Program size

Deployed program size determines rent, and rent is paid in SOL. A Pina program is built to be small: the framework’s own overhead above a hand-written pinocchio program is a few hundred bytes once link-time optimization runs.

Framework comparison

Same toolchain for every row: cargo-build-sbf (Agave 4.2.2), sbpf-solana-solana target, equivalent program semantics — a single-instruction hello world, and a PDA counter with initialize/increment.

FrameworkHello worldCounter
Quasar2,5207,808
Pinocchio (hand-written)3,1606,512
Pina1,6167,592
Anchor v2 (lang-v2, rc.1)1,8808,696
Anchor (v1, 1.2.0)55,752122,160

Framework comparison holds the generated version of this table together with the compute units each instruction consumes, and benchmark:frameworks rebuilds and rewrites it. The headline: a v1 Anchor program is more than ten times the size of any of the others, and LTO makes it larger rather than smaller.

Pina’s hello world is smaller than the hand-written Pinocchio program, which carries pinocchio’s full account deserializer and its out-of-line error conversion. Its counter is 1,080 bytes over Pinocchio’s, mostly derive-generated validation that the hand-written program does not perform.

What determines the size

Four decisions account for nearly all of it. pina build applies the first three automatically.

lto = "fat" with codegen-units = 1 is worth 20-35% on its own.

A crate that declares crate-type = ["cdylib", "lib"] cannot be linked with LTO: rustc rejects -C lto when one invocation also emits an rlib, and the SBF toolchain silently drops the profile setting. Programs are therefore crate-type = ["cdylib"] only. See Testing for how tests still reach the real code.

Check the entrypoint’s stack frame after switching. LTO inlines every instruction handler into the entrypoint, and the SBF runtime allows 4 KB of stack per frame. A program with enough handlers can exceed that, and the failure is quiet: cargo-build-sbf prints

Error: Function entrypoint overflows the maximum allowed frame space by accessing
an offset 1088 bytes greater than the maximum of 4096. Estimated function frame
size: 5184 bytes.

to stderr but still exits 0 and writes the .so, so a build that looks successful can produce a program that faults at runtime. A real case: a 55-instruction program at 446 KB built fine with ["cdylib", "lib"], and switching to ["cdylib"] alone — before any version change — produced a 5,184-byte frame.

When this happens, either keep ["cdylib", "lib"] and forgo LTO, or raise the limit with cargo build-sbf --sbf-stack-size <BYTES>. Raising it is a runtime-budget decision, not a free change, so treat it the way you would any other resource limit.

2. Diagnostics

Failure paths are the largest avoidable cost. log!("address: {} …", addr) and caller locations pull all of core::fmt into the binary, which is far more expensive than the message strings themselves.

BuildHello worldCounter
Formatted diagnostics (pre-0.17 default)8,68037,472
Static diagnostics, logs on (default)4,80012,392
verbose-logs on6,84024,504
logs off entirely4,69618,680

The default logs feature now logs a fixed message per failure and keeps the descriptive text. Formatted detail and file:line:column locations moved to the opt-in verbose-logs feature.

A fixed message is only logged when it tells a client something the error code does not. Five validations fail with a code that names exactly one check — MissingRequiredSignature, InvalidAccountOwner, AccountAlreadyInitialized, UninitializedAccount, and pina’s InvalidAccountSize — so their messages are logged only with verbose-logs, alongside the account’s address. Each such site cost about 150 bytes, because a logging branch cannot be merged with the other failures that return the same code. Messages that tell apart the causes of a shared code, such as the several reasons for InvalidAccountData or an account discriminator versus an instruction discriminator, are still logged by default.

[dependencies]
pina = { version = "0.17", features = ["logs", "derive"] }

Add verbose-logs while debugging:

pina build --features verbose-logs

Programs can also choose their own detail level per call:

#![allow(unused)]
fn main() {
use pina::*;

// A fixed message in every configuration.
log!("initialized");

// A formatted message. Only this call site pays for `core::fmt`.
log_verbose!("initialized counter {} for {}", index, authority.address());
}

The log! format arm collapses to the static DETAIL_POINTER_MESSAGE when verbose-logs is off, so the same source compiles to a small binary in production and detailed logs in development.

3. The release profile

[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
overflow-checks = false

overflow-checks = false lets arithmetic overflow wrap instead of panicking, which is why it is opt-in. Use pina build --overflow-checks when a program must fail loudly. pina build --no-size-profile skips every override.

An explicit overflow-checks = true in the workspace release profile outranks the size profile. Cargo profile environment variables beat [profile.release], so without that precedence rule pina build would silently turn the manifest’s opt-in into wrapping arithmetic. When the manifest opts in, pina build keeps the checks on, warns, and gives up only that part of the profile.

Deterministic verified builds (pina build --verify) never apply profile overrides. A verified artifact has to stay reproducible from its recorded Git revision, and an override that exists only on the command line cannot be reproduced by anyone rebuilding that revision. Declare the profile under [profile.release] in the workspace manifest — as pina init does — so the ordinary and verified backends agree. Pina warns when a requested size profile would make the two artifacts differ.

Every deploy path carries the profile: pina build, pina test, and pina dev all build through the default Production size profile, pina init writes the settings above into the generated workspace manifest, and the workspace’s own example programs build with --lto through the cargo build-<program> aliases and the CU-measurement scripts. Raw cargo build-sbf without --lto remains the one way to build a ["cdylib"]-only program 12-37% larger than every supported path.

4. Dependency features

Only enable what the program uses. token, memo, and compact each pull in their own dependencies. An unused dependency is removed entirely by the linker, so optional features save build time more than binary size — but a token program that also links pinocchio-token-2022 pays for both.

Measuring a program

pina build
wc -c target/deploy/my_program.so
pina profile target/deploy/my_program.so        # static CU estimates
pina profile target/deploy/my_program.so --json # machine-readable

Rent follows the ELF size directly: Loader v3 stores the raw ELF in the program data account, and rent is charged per byte. Shrinking a program by 10 KB saves roughly 0.07 SOL in one-time deployment rent.

Testing a cdylib program

Cargo cannot link a cdylib into an integration test, and a separate test crate cannot depend on it either. Three patterns cover every test you need without giving up LTO.

Unit tests live in src/lib.rs. This is the coverage path: the tests compile from the same source that ships, so cargo llvm-cov measures real code. #[cfg(test)] mod tests is included in the pina init template.

#![allow(unused)]
fn main() {
// src/lib.rs
#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn decodes_initialized_instruction() {
		let mut data = [0u8; InitializeInstruction::SIZE];
		InitializeInstruction::initialize(&mut data, |args| {
			args.value = 7;
			Ok(())
		})
		.expect("initialize instruction storage");

		let decoded =
			InitializeInstruction::try_from_bytes(&data).expect("decode initialized instruction");
		assert_eq!(decoded.value, 7);
	}
}
}
cargo test --lib
cargo llvm-cov --lib --summary-only

On-chain tests include the program source. tests/surfpool is its own crate, so it can add pina as a dependency and pull in src/lib.rs directly. The instruction encoders and program ID it uses are the real ones.

#![allow(unused)]
fn main() {
// tests/surfpool/src/lib.rs
#[path = "../../../src/lib.rs"]
mod program;

use program::ID;
use program::InitializeInstruction;
}

Integration tests keep artifact-level assertions. tests/integration.rs cannot reference program types, so keep it to things that only need the build output.

!!! note “Why not use my_program::*” That requires an rlib, which costs 20-35% of deployed size. Including the source with #[path = ...] gives tests the real types without the rlib. The one trade-off: #[path] includes create a second copy that cargo llvm-cov reports as uncovered, so put behavioural tests in src/lib.rs where coverage is measured and use #[path] only where a test needs to drive the program’s own types from another crate.

Keeping size from regressing

scripts/compare-compute-units.ts records ELF size alongside compute units for every example program, and the benchmark report on each pull request includes a size column. Treat an increase the same way you treat a compute-unit increase: investigate it, or keep the pull request unmerged.

Every example program now builds as ["cdylib"] only and is compiled with fat LTO everywhere it is built for deployment or measurement: the cargo build-<program> aliases carry --lto, and scripts/build-runtime-compute-units.ts derives the flag from each manifest through the example inventory’s ltoEligible flag, so the PR benchmark measures the artifact users actually deploy. A program whose harness needs the crate as a library follows the same three steps: drop "lib" from [lib] crate-type, replace the tests/surfpool rlib dependency on the program with a #[path = "../../../src/lib.rs"] source include (mirroring the program’s pina features in the test crate, and re-exporting program::* at the harness root when the program’s submodules use crate:: paths), and add --lto to the program’s build alias.

Measured on the same commit, fat LTO compared with the plain --features bpf-entrypoint build. The first five were converted and measured first; the remaining twenty-two follow:

ProgramWithout LTOWith LTOΔ size
escrow73,97646,680−37%
staking-rewards87,26459,280−32%
vesting57,63246,856−19%
multisig195,752166,792−15%
hello-solana5,6564,712−17%
counter36,05618,160−50%
migrations57,69639,624−31%
privacy-pool165,064130,512−21%
profile25,99217,432−33%
role-registry32,76822,784−31%
validation26,06417,480−33%
optional-accounts24,76815,824−36%
todo22,53613,856−39%
account-realloc29,72020,848−30%
compact-accounts42,84033,528−22%
remaining ten programs−7…−56% each

Across the twenty-two programs converted in the second pass the aggregate is 551,208 → 397,912 bytes (−27.8%), with per-program cuts from −7.5% (custom-errors, already minimal) to −49.6% (counter) and −53.6% (float-accounts).

Runtime compute units were verified on escrow with Surfpool, three runs each, fully deterministic: Make 29,535 → 29,105 CU and Take 32,230 → 31,658 CU, and the framework-comparison fixtures re-measured byte- and CU-identical after the creation-builder dedup below (counter initialize 3,295 CU). Fat LTO does not trade compute units for size here; it removes them, because the single codegen unit lets inlining collapse cross-crate glue that the unoptimized link kept as call sequences. Every entrypoint frame stays within the 4 KB stack limit after the switch (deepest: vesting at 3,992; multisig reaches exactly 4,096 — no offset exceeds it, its manifest documents the re-check rule, and its full e2e + Surfpool suites pass against the LTO ELF).

The PDA-creation builders share one allocation spine (CompactCreationTarget::allocate_zeroed, PdaCreationTarget::allocate), so a program that creates several account types pays the seed marshalling, signer assembly, and rent computation once instead of once per generic instantiation — the multisig example keeps 5,252 bytes this way. Each spine keeps the shape its users measured best with: the compact-creation spine stays a real outlined call, which is what collapses multisig’s three instantiations into one shared function, while the PDA-creation spine is #[inline(always)], because a single-instantiation program has no duplicate to collapse and pays only the call boundary — outlining it measured +80 CU on the counter fixture’s initialize.

Dispatch before parsing accounts

dispatch_entrypoint! replaces nostd_entrypoint! for a program routed by #[discriminator(entrypoint)]. Since SIMD-0321, active on every public cluster, the loader passes the entrypoint a pointer to the instruction data, so the router can read the discriminator before it touches an account. It then walks only the accounts the routed struct reads, instead of walking every account through pinocchio’s deserializer first:

dispatch_entrypoint!(CounterInstruction);

#[derive(Accounts)] declares two counts for this: ACCOUNT_LIMIT, the most accounts the struct reads, and ACCOUNT_MINIMUM, the fewest it accepts. The router picks each route’s walk from them at compile time:

  • Exact, when the two are equal: the route parses a fixed-length array, so the struct’s own length checks fold away, and any other count fails before the struct runs, with the error its parser would return.
  • Bounded, when trailing optional fields may be absent: the walk rejects more than ACCOUNT_LIMIT accounts, and the struct reports a missing required one.
  • Leading, for a struct with a #[pina(remaining)] slice, a hand-written parser, or the reserved Migrate route: the walk reads up to ENTRYPOINT_ACCOUNT_CAPACITY accounts and ignores the rest, which is what nostd_entrypoint! hands those routes.

Every check the routed struct and handler make still runs, and the program-ID and discriminator checks still come first. Account counts now take precedence over per-account checks: an instruction with more accounts than its struct reads fails with TooManyAccountKeys, and one with fewer than a fixed-length struct reads fails with NotEnoughAccountKeys, where the struct’s parser could report an earlier field’s failure first.

The walk is fastest inlined: a route with a fixed account count unrolls it and folds the checks its positions make impossible. Every route then carries its own copy, though, so a router with more than two routes calls one shared copy instead; the reserved Migrate route follows that choice without counting toward it. The shared copy costs 10 to 40 compute units per instruction and keeps large routers from growing.

Programnostd_entrypoint!dispatch_entrypoint!Change
hello comparison fixture1,9841,616−368
counter comparison fixture8,4247,592−832
counter_program13,19212,984−208
migrations_program39,51237,952−1,560
escrow_program41,91238,912−3,000
staking_rewards_program52,50450,192−2,312

The first two routes’ walks are inlined; escrow_program and staking_rewards_program, with seven and eight routes, share one. On the fixtures, hello fell from 146 to 136 compute units and the counter’s increment from 378 to 360.

A large router can still come out larger, because every route parses its own fixed-length struct where the old router’s identical parsing code merged. multisig_program, with twenty routes, measured 1,448 bytes larger, so it keeps nostd_entrypoint!. Measure both entrypoints on a program with many routes before switching.

Bound the entrypoint account array

nostd_entrypoint! accepts a second argument: the size of the account array the entrypoint deserializes into (the default is pinocchio::MAX_TX_ACCOUNTS, 255). Pinocchio’s deserializer walks accounts five at a time and then copies the remaining one to four through an unrolled match. With five or fewer slots, the five-account loop can never run, so it disappears from the program. With more slots the loop stays, and a bounded array adds a second loop that skips accounts past the array, so the program grows instead.

The loader does not reject accounts beyond the array; it skips them. An array sized to exactly the widest instruction therefore hides an extra trailing account from finish_exact, and an over-supplied instruction that pina would otherwise reject with TooManyAccountKeys runs instead. The safe bound is one slot larger: the spare slot keeps the first extra account visible, so every instruction whose accounts struct ends with finish_exact still rejects it, however many extras follow.

#[discriminator(entrypoint)] computes that bound as ENTRYPOINT_ACCOUNT_CAPACITY: one more than the most accounts any route accepts, which is each accounts struct’s ACCOUNT_LIMIT and the reserved Migrate route’s slot count. A route that accepts any number of accounts makes it the full 255: a struct with a #[pina(remaining)] slice, directly or through a nested account group, and a hand-written parser that declares no limit. The declared ACCOUNT_BOUND, which counts a trailing slice as one slot, is not a limit, so the capacity never uses it. Pass the capacity as the second argument when it is five or less:

nostd_entrypoint!(
	CounterInstruction::process_instruction,
	CounterInstruction::ENTRYPOINT_ACCOUNT_CAPACITY
);
ProgramCapacityDefault arrayBounded arrayΔ size
hello fixture24,6802,944−1,736
counter fixture410,4569,416−1,040
examples/counter_program416,09614,896−1,200
examples/migrations_program> 539,62439,808+184
examples/escrow_program> 544,71245,104+392
examples/staking_rewards_program> 557,45657,920+464

The bounded walk also costs a few compute units, because the deserializer caps the account count and checks for accounts to skip: +1 on the hello fixture’s hello, +1 on the counter fixture’s initialize, and +4 on its increment. A smaller array does shrink the entrypoint’s stack frame by eight bytes per slot removed, which matters for a program close to the 4 KB frame limit even when it costs size.

Accounts past the spare slot are never materialized, so the one observable difference from the full array is error precedence: a writable account whose duplicate sits past the spare slot fails with TooManyAccountKeys rather than DuplicateMutableAccount. Both reject the instruction. A program with a hand-written router, or one that reads accounts outside its routed accounts structs, must size its array itself by the same rule — its widest instruction plus one — and must keep the default when any instruction accepts unbounded trailing accounts.

Prefer exclusive slice bounds in seed and signer assembly

Every [..=len] slice over a seed or signer array monomorphizes its own 216-byte RangeInclusive<usize>::index copy plus panic plumbing; the equivalent exclusive [..len + 1] inlines to a few instructions. Pina’s PDA-creation CPI spine carried three of them — the derivation-seed slice, the combined-seed signer slice, and the signer-list slice — so every program that creates a PDA paid ~1.3 KB of deployed size for them. They now use exclusive bounds (each guarded by the len < MAX check that already ran), which measured −1,320 bytes and −92 compute units on the counter fixture’s initialize with byte-identical behavior. Generated code and user code should follow the same shape: exclusive ranges over arrays whose filled prefix is len + 1.

Size arrays that escape to syscalls by what the syscall reads

A stack array passed to a syscall keeps every store to it, because LLVM cannot see that the runtime reads only a prefix. Safe Rust initializes every slot, so a [Seed; MAX_SEEDS] that carries two seeds and a bump still costs sixteen slot writes. Pina’s PDA-creation spine used to build three such 16-slot arrays per created account. It now sizes the derivation and signer seeds to the smallest of 4, 8, or 16 slots that holds the seeds plus the bump, and skips the signer array entirely when there are no extra signers. Because every #[pda] seed array has a compile-time length, only one size survives inlining. On the counter fixture that measured −944 bytes and −124 compute units on initialize. The same applies to program code: when a fixed-capacity array only exists to hand a prefix to a syscall, size it by the prefix the call actually uses.

Convert entrypoint errors where they are returned

The entrypoint turns a ProgramError into the u64 status the runtime reads. Pinocchio’s program_entrypoint! does this in an #[inline(never)] function, an 864-byte comparison tree over every ProgramError variant that every program carries, even one whose errors are all constants. nostd_entrypoint! declares its own entrypoint instead: it deserializes accounts through the same pinocchio function and converts the error inline, so an error returned by value folds to its status code where it is returned. The comparison tree is only emitted for errors the compiler cannot see through, such as one returned by an outlined helper or a CPI. That measured −856 bytes on the hello fixture, which drops the tree entirely, and −1,048 on escrow_program, at no compute-unit cost.

Keep a shared check’s error at its call site

An out-of-line function that returns ProgramResult hands its result back through memory, so its caller cannot see which error it carries, and neither can the entrypoint’s inline conversion. assert_address was the common case: LLVM kept validate_address out of line because the program and sysvar checks call it too. The address comparison and its failure log now live in one out-of-line helper that returns bool, while validate_address and assert_address are always inlined, so every call site returns InvalidAccountData as a constant and keeps a single copy of the comparison. Across the examples this measured smaller for 14 programs (−8 to −664 bytes), unchanged for 12, and +184 bytes for privacy_pool_program, where LLVM’s inlining choices leave six more out-of-line call sites (+88 bytes of code, +96 of relocations). The counter fixture fell 80 bytes to 8,632.

Inlining the whole check instead, comparison and log at every call site, measured −248 bytes on the counter fixture but grew programs with many address checks by up to 624 bytes. On the SBF target each call to an out-of-line function also costs a 16-byte relocation next to its 8-byte instruction, so a helper pays for itself only when enough call sites share it. Program code can use the same shape: give a check that many call sites share an out-of-line part that returns bool, and build the error where it is returned.

Framework comparison

Two programs, built with four frameworks, measured two ways. Size decides what a deployment costs in rent; compute units decide how much of a transaction’s budget the instruction spends. Both tables below are produced by one command:

devenv shell -- benchmark:frameworks

That command rebuilds every program and rewrites the generated region of this page, so the published numbers cannot drift from the code that produced them.

What is measured

Every program is a standalone crate under benchmarks/framework-comparison/programs. There are two of them:

  • Hello world — one instruction, one signer check, one static log line.
  • Counter — a PDA seeded by b"counter" + authority, a ten-byte account holding a discriminator, a bump and a u64 count, with initialize and increment instructions.

Both are deliberately tiny. The difference between the frameworks is the framework’s own dispatch, validation, and entrypoint code, not application logic — which is exactly the overhead the table is meant to expose.

Size is the deployed .so in bytes. Compute units are measured by executing each instruction in a Mollusk VM and reading the counter the runtime charged, so they include CPI costs the program triggers. A program that fails, or that does not leave the expected account state, aborts the run rather than reporting a fast number for work it never did.

Build configuration

The comparison is meant to show framework overhead, not build settings, so every row is built as favourably as possible and identically:

  • cargo build-sbf --lto (Agave 4.2.2, sbpf-solana-solana target)
  • lto = "fat", codegen-units = 1, opt-level = 3, overflow checks off
  • crate-type = ["cdylib"] only, which is what lets LTO apply at all

Each program uses its framework’s recommended entrypoint. For Pina that is dispatch_entrypoint! over the #[discriminator(entrypoint)] router: it reads the instruction through the loader’s instruction-data pointer, then walks only the accounts the routed instruction reads, rejecting extra trailing accounts.

See Program size for why those settings matter and what each one is worth on its own.

Results

Hello world

FrameworkSize (bytes)hello CUvs Pinocchio size
Pina1,616136−49%
Pinocchio (hand-written)3,160111+0%
Quasar2,520115−20%
Anchor v2 (lang-v2, rc.1)1,880127−41%

Counter

FrameworkSize (bytes)initialize CUincrement CUvs Pinocchio size
Pina7,5921,694360+17%
Pinocchio (hand-written)6,5121,4901,721+0%
Quasar7,8083,488330+20%
Anchor v2 (lang-v2, rc.1)8,6963,4582,117+34%

Reading the numbers

The account layouts are not identical in every row. Pina, Pinocchio and Quasar store the counter as discriminator, bump, count — ten bytes. Anchor v2 prefixes an eight-byte discriminator, which makes its account twenty-four bytes after alignment, so its initialize pays more for the create_account CPI. That is inherent to the framework’s account model rather than a tuning choice, and it is the main reason Anchor’s counter numbers are not directly comparable instruction-for-instruction.

Pina and Pinocchio receive the PDA bump as an instruction argument; Quasar and Anchor derive it from the declared seeds on-chain. Deriving a bump costs a PDA search the other two avoid, so initialize is not purely a framework overhead comparison.

The Pinocchio row is the floor. It is hand-written pinocchio with no framework at all, and it is the number a framework has to justify. Pina’s gap to it is the cost of derive-generated dispatch and validation.

Pina’s initialize was the one number that looked like a defect. The first measurement of this page caught it at 10,719 CU against Pinocchio’s 1,490 for the same create_account CPI: CreateProgramAccountWithBump validated the PDA with a full canonical bump search although the caller had already supplied the bump. The counter now uses CreateProgramAccountWithUncheckedBump, which checks one derivation instead of searching, and checks it with sha256 rather than the sol_create_program_address syscall, because the runtime repeats the curve check when it signs the allocation (see Security model). initialize measures 1,694 CU.

The distinction between the two builders is the one to keep in mind when reading the row. The canonical builder proves the supplied bump is the highest valid one, so a seed namespace maps to exactly one address; the unchecked builder proves only that the supplied bump derives the account’s address, which is about 9,200 CU cheaper per creation. A non-canonical bump creates a second valid address that canonical derivation will not find — harmless for a per-authority counter, wrong for a vault that another program derives by seed alone. Every PDA-creating example in this repository now uses the unchecked builder, which is why the row reads the way it does.

Reproducing

devenv shell -- benchmark:frameworks

The command needs the Agave SBF toolchain (for cargo build-sbf) and network access on the first run, because the Quasar and Anchor v2 programs depend on pinned revisions of their upstream repositories. Both revisions are pinned in the fixture manifests, and each fixture is a standalone crate so those dependencies never enter the workspace lockfile.

Regenerating rewrites the tables in whatever alignment the script emits, so follow it with fix:format to restore dprint’s column alignment.

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


The quickest start is pina init hello_solana_program, which writes everything below plus tests, a pinned toolchain, and client configuration. To see each piece, create the crate by hand instead:

# Cargo.toml
[package]
name = "hello_solana_program"
version = "0.0.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[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. Keep it the only crate type: adding lib makes rustc reject link-time optimization for the program, which costs 20-35% of the deployed size. Tests reach the source through a #[path = "../src/lib.rs"] module instead of linking the crate.

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 native PinaPod schema whose first field is an auto-injected discriminator byte. Tests initialize caller-owned zeroed storage and configure the generated HelloInstructionDataZc view:

#![allow(unused)]
fn main() {
#[instruction(discriminator = HelloInstruction::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((program_id, 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_program/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::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((program_id, accounts))?.process(data)
			}
		}
	}
}
}

Building for SBF


Compile the program for the Solana SBF target with the Agave CLI’s cargo-build-sbf, which provides the SBF toolchain and linker:

pina build
# or, without the IDL refresh:
cargo build-sbf --sbf-out-dir target/deploy -F bpf-entrypoint

pina build also enables fat LTO for a cdylib-only crate and writes the program’s IDL next to the artifact.

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 the counter_program example.
  • Handle multiple instructions by adding more variants to your discriminator enum.
  • Add PDA-based accounts with canonical CreateProgramAccount; use invoke_with_bump when the account stores its bump.
  • Follow the Token Escrow Tutorial for a production-shaped exercise with token transfers and CPI, then apply the Production Readiness gate before deployment.

Compact accounts

Compact mode is for discriminator-first accounts with a fixed header and one or more bounded, variable-length tails. It is a good fit when unused collection capacity should not consume rent.

Declare the layout

Compact mode stores a fixed header followed by one or more bounded tails. Enable compact for the schema and checked loaders. Enable account-resize to apply a patch and adjust rent in one operation:

[dependencies]
pina = { version = "...", features = ["compact", "account-resize"] }

The compact feature also enables derive. Declare fixed fields first, then the compact tails. String<N> uses a one-byte length prefix, and Vec<T, N> uses a two-byte length prefix. Use PodString<N, PFX> or PodVec<T, N, PFX> when the schema needs an explicit prefix width. PFX is a byte count and must be 1, 2, 4, or 8.

#![allow(unused)]
fn main() {
#[account(discriminator = AccountType, compact)]
pub struct Journal {
	pub bump: u8,
	pub authority: Address,
	pub revision: u32,
	pub featured_entry: Option<u64>,
	pub title: String<24>,
	pub entries: Vec<u64, 8>,
	pub markers: PodVec<u8, 8, 8>,
	pub note: Option<String<64>>,
}
}

The compact grammar accepts these tail forms:

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

Option<T> for fixed T stays in the header. The other forms use tail storage. A compact schema can contain several tails, but it cannot place a fixed field after the first tail. The macro rejects unsupported nesting and prints the accepted forms in its error.

N and M accept an integer literal or a const item, so a bound declared once is reused everywhere it appears. Fixed [T; N] fields and instruction arguments accept the same forms. A constant may be written in terms of other constants and may live in any module of the crate. Pina resolves it during expansion and records the number, so the generated constants, the ABI manifest, and the Codama IDL are identical to the literal spelling:

#![allow(unused)]
fn main() {
const MAX_MEMBERS: usize = 24;

#[account(discriminator = AccountType, compact)]
pub struct Roster {
	pub bump: u8,
	pub slots: [u8; MAX_MEMBERS],
	pub members: Vec<Address, MAX_MEMBERS>,
}
}

A capacity Pina cannot evaluate fails the build with a diagnostic naming the expression, rather than reaching the ABI layer as a name it cannot size. An associated constant such as Bounds::MAX_MEMBERS is not resolved: declare the bound as a const item at the crate root or in a module.

The macro generates JournalHeader, JournalRef, and JournalPatch. It also generates HEADER_SIZE, MIN_SIZE, MAX_SIZE, checked reads, initialization, projected-size calculation, and atomic updates. MIN_SIZE equals HEADER_SIZE. Tail prefixes live in the header except for a present Option<String<N>> or Option<Vec<T, N>>, whose payload retains its own prefix. Each active element of Vec<String<M>, N> occupies the fixed String<M> footprint, although each string keeps its own logical length.

Pina uses PinaPod for validated alignment-one storage. PinaPod initializes inactive collection capacity and validates each active nested value before Pina returns safe access.

When a compact account also declares #[pda(..., bump = bump)], the macro generates two closure-scoped loaders that differ in how they treat the canonical bump. Type::with_stored_bump_pda (named with_pda before the split that gave the loaders distinct names) derives one address from the stored bump, so it checks owner, compact data, and the stored-bump PDA address while one runtime borrow remains active. Type::with_checked_pda searches the seeds for the canonical bump instead, which additionally rejects an account at any other address and a stored bump that is not canonical. That search makes it the only compact loader that rejects a shadow account created at a noncanonical bump, and it costs more compute than the single derivation with_pda performs.

Dynamic fields must form the final suffix. Capacities accept an integer literal or a const item; explicit prefix widths must be integer literals. The macro rejects a fixed field after the first tail and any nesting outside the grammar above.

Calculate size from elements

Use the generated patch API to calculate account size. Manual arithmetic duplicates the generated header, option, and element-footprint rules:

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

let target_size = Journal::updated_len(current_data, &patch)?;
}

MIN_SIZE and HEADER_SIZE are the smallest valid allocation. MAX_SIZE is the largest. The exact encoded length depends on the active values:

  • String<N> contributes its UTF-8 byte length.
  • Vec<T, N> contributes len * size_of::<T::Pod>().
  • An absent dynamic Option contributes no tail bytes.
  • A present dynamic Option contributes its prefix and active payload.
  • Vec<String<M>, N> contributes len * size_of::<PodString<M>>(). Each element has a fixed footprint.

PinaCompactAccount::validate_size rejects a buffer outside MIN_SIZE..=MAX_SIZE. Checked loading also validates every length, option tag, active element, and UTF-8 sequence. A JournalRef reports encoded_len(), storage_len(), and spare_capacity() without exposing mutable length metadata.

Prefer logical values in instruction data. Let the generated patch calculate bytes so callers do not need to reproduce header, option, or element-footprint rules.

Create at the needed size

Use CreateCompactProgramAccount when Pina should derive the canonical bump. Pass an ordinary patch to invoke, or use invoke_with_bump when the patch stores that bump. CreateCompactProgramAccountWithBump accepts instruction data only when it matches the canonical bump. The builders perform this validation themselves, so do not call assert_canonical_bump or assert_seeds_with_bump first.

#![allow(unused)]
fn main() {
CreateCompactProgramAccount {
	account: journal,
	payer: authority,
	owner: &ID,
	seeds: &Journal::seeds(authority.address()).as_slices(),
	space: Journal::HEADER_SIZE,
}
.invoke_with_bump::<Journal, _>(|bump| {
	JournalPatch::new()
		.bump(bump)
		.authority(*authority.address())
		.revision(0)
})?;
}

The typed create builder applies the patch while it initializes the account. Omitted patch fields use their zero, empty, or absent representation. Use JournalPatch::new() when all header fields may remain zero and every tail starts empty. To create nonempty tails, add their replacement methods to the patch and allocate enough space for the encoded values. UpdateResizableAccount can populate or replace several tails later.

Grow, update, shrink, and clear

Apply all compact changes through one patch:

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

UpdateResizableAccount preflights the patch’s structural representation and calculates the final encoded length before it changes the account. It grows the allocation before applying a longer representation. For a shorter representation, it applies the patch before shrinking the allocation. If the allocation stays the same size, the builder skips the resize. It adjusts the rent balance through rent_account and clears bytes removed by the patch. A structural or size preflight failure leaves account data and lamports unchanged.

With the validation feature, Pina checks application rules on the completed compact representation after applying the patch. Always propagate an update error with ?; Solana transaction rollback is what restores the previous bytes and any rent moved earlier in the instruction.

Use invoke_signed::<Journal>(signers) when rent_account is a PDA that must sign the system transfer used for growth. The patch and resize ordering stay the same.

The rent_account field has the same meaning across UpdateResizableAccount, ReallocAccount, ReallocAccountZeroed, and ReallocCompactAccount: it funds growth and receives a shrink refund. The lower-level builders take an explicit target_size; the high-level builder derives it from the patch.

The generated patch owns the update plan, so callers do not coordinate set_*, commit, and ReallocCompactAccount. Use Journal::with_pda to read a stored-bump compact PDA without a separate assert_compact_type or assert_seeds pass. End the closure before invoking UpdateResizableAccount.

Solana limits the per-instruction increase of account data. A schema can declare a larger MAX_SIZE, but callers may need multiple transactions when a single growth step would cross that runtime limit.

Load safely

Immutable reads are closure-scoped so Pinocchio’s borrow guard stays alive for the compact view:

#![allow(unused)]
fn main() {
let (revision, count) = Journal::with_pda(
	journal,
	authority,
	&ID,
	|state| Ok((state.revision.get(), state.entries().len())),
)?;
}

Journal::with_pda checks the owner, discriminator, size, prefixes, active elements, and the address the stored bump derives before the closure runs, using a single derivation. Journal::with_checked_pda searches for the canonical bump instead, which also rejects a shadow account created at a noncanonical bump; use it when an untrusted caller chooses which account the handler loads. Check the signer and stored authority separately because loading an account does not grant authority.

For a compact account without a stored bump, use with_compact_account::<T, _>. Use assert_compact_type::<T> only when code validates the account without reading its fields. Calling it before either loader repeats the complete compact-data validation.

Codama clients

pina idl emits compact tails with their prefix and capacity metadata. pina generate creates Rust, TypeScript, and Dart clients that reject over-capacity values at encode and decode boundaries. Client applications submit logical values and let the generated patch calculate account bytes.

Use-case checklist

  • Supply the required generated patch to every compact create builder, including JournalPatch::new() for an all-zero, empty-tail default.
  • Create empty compact state with space: T::MIN_SIZE; allocate enough space for any nonempty values included in the initial patch.
  • Read an ordinary compact account through with_compact_account::<T, _>.
  • Read a stored-bump compact PDA through its generated Type::with_stored_bump_pda helper, or Type::with_checked_pda when an untrusted caller chooses which account the handler loads.
  • Replace fixed fields and several tails in one generated patch.
  • Grow or shrink up to T::MAX_SIZE through UpdateResizableAccount.
  • Treat rent_account as both the growth funder and the shrink refund recipient.
  • Reject unsupported nesting at compile time and reject corrupt prefixes, tags, UTF-8, and elements at load time.
  • Keep signer, writable, and stored-authority checks explicit. Type::with_stored_bump_pda covers owner, layout, and stored-bump PDA address validation; Type::with_checked_pda adds the canonical bump search.
  • Regenerate the IDL and clients after a compact schema changes. Do not edit generated clients by hand.

The complete compact_accounts_program example includes unit coverage and isolated Surfpool tests for creation, nonempty initialization, growth, same-size mutation, maximum capacity, shrink, clear, rent adjustment, and rejected authorization and bounds cases.

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
  • load existing canonical ATAs with as_associated_token_account(), which validates the token-program owner, derived address, stored current authority, and stored mint together
  • 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::Reallocate;
use pina::token_2022::state::ExtensionType;

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 = [ExtensionType::MemoTransfer, ExtensionType::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, but assert_writable() should still appear in the runtime validation chain
  • borrow guards should stay short-lived around token CPIs
  • as_token_mint() and as_token_account() require the original SPL Token owner
  • as_token_2022_mint() and as_token_2022_account() require the Token-2022 owner
  • use *_for_program() when the instruction accepts either canonical token program at runtime
  • use assert_owner() or assert_associated_token_address() only for validation-only paths that do not need typed token state

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


  1. Make – the maker deposits token A into a PDA-owned vault and records the desired amount of token B in an escrow state account.
  2. 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,
	EmptyOffer = 2,
}
}

Each variant’s numeric value becomes the custom error code. You can return these from any processor via Err(EscrowError::OfferKeyMismatch.into()).

Wire values are part of the program ABI: add a new variant with the next value instead of reusing an existing code, and give every variant a doc comment, because the generated IDL uses it as the message that clients and explorers display. Make returns EmptyOffer before any CPI when either side of the offer is zero, so a fat-fingered amount cannot hand token A over for nothing.

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: u64,
	pub amount_b: u64,
	pub seed: u64,
	pub bump: u8,
}
}

The macro auto-injects a discriminator field as the first byte, set to EscrowAccount::EscrowState, and derives PinaPod’s native-schema machinery. EscrowStateZc is the generated storage view. Its integer fields are alignment-one little-endian wrappers, and account loaders return that validated view without copying.

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::Make)]
pub struct MakeInstruction {
	pub seed: u64,
	pub amount_a: u64,
	pub amount_b: u64,
	pub bump: u8,
}

#[instruction(discriminator = EscrowInstruction::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. Pina’s #[pda] attribute declares the typed seeds once on the account struct:

#![allow(unused)]
fn main() {
#[account(discriminator = EscrowAccount)]
#[pda(seeds = [SEED_PREFIX, maker: Address, seed: u64], bump = bump)]
pub struct EscrowState {
	pub maker: Address,
	// ...
	pub seed: PodU64,
	pub bump: u8,
}

/// Seed prefix for escrow PDAs.
const SEED_PREFIX: &[u8] = b"escrow";
}

The attribute generates:

  • EscrowState::seeds(maker, seed) – a typed seeds struct with as_slices() (without bump) and with_bump(bump) (with bump).
  • EscrowState::try_find_pda(...) / find_pda(...) – canonical PDA derivation.
  • EscrowState::assert_seeds(account, ...) – verifies an existing account against the stored bump field, avoiding a canonical bump search on-chain.

Supported seed types are Address, u8, u16, u32, u64, [u8; N], and const &[u8] references.

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 = EscrowState::seeds(&maker_address, u64::from(args.seed));

		// Validate all accounts before mutating anything.
		self.token_program.assert_addresses(&SPL_PROGRAM_IDS)?;
		let token_program = *self.token_program.address();
		self.maker.assert_signer()?;
		drop(
			self.mint_a
				.as_token_mint_for_program(&token_program)?
				.assert_no_extensions()?,
		);
		drop(
			self.mint_b
				.as_token_mint_for_program(&token_program)?
				.assert_no_extensions()?,
		);
		drop(self.maker_ata_a.as_associated_token_account(
			self.maker.address(),
			self.mint_a.address(),
			&token_program,
		)?);
		self.escrow.assert_empty()?.assert_writable()?;
		self.vault.assert_empty()?.assert_writable()?;

		// ... create accounts and transfer tokens ...
		Ok(())
	}
}
}

Key validation patterns:

  • assert_addresses checks that the token program is either SPL Token or Token-2022.
  • assert_signer ensures the maker signed the transaction.
  • as_token_mint_for_program accepts only a canonical token program, checks the mint owner, and parses its concrete layout.
  • as_associated_token_account checks the canonical token-program owner, derived address, stored current authority, and stored mint. The escrow separately owns any required state, delegate, close-authority, and Token-2022 extension policy.
  • assert_empty and assert_writable validate the initialization state and runtime permissions. The creation builder validates the canonical PDA itself.
  • assert_empty and assert_writable cover the vault, and the Create CPI that follows binds the address: the associated token program derives the same [wallet, token_program, mint] seeds and rejects a mismatch with InvalidSeeds before it creates anything. Keep an explicit assert_associated_token_address only on validation-only paths that never reach an ATA instruction.

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() {
CreateProgramAccountWithBump {
	account: self.escrow,
	payer: self.maker,
	owner: &ID,
	seeds: &escrow_seeds.as_slices(),
	bump: args.bump,
}
.invoke_with::<EscrowState>(|escrow| {
	escrow.maker = *self.maker.address();
	escrow.mint_a = *self.mint_a.address();
	escrow.mint_b = *self.mint_b.address();
	escrow.amount_a.set(0);
	escrow.amount_b = args.amount_b;
	escrow.seed = args.seed;
	escrow.bump = args.bump;
	Ok(())
})?;
}

CreateProgramAccountWithBump::invoke_with first derives the canonical PDA and rejects args.bump if it differs. It then issues a CreateAccount CPI, allocates EscrowState::SIZE bytes, writes the discriminator, runs the initializer, and validates the completed state. Do not add separate assert_canonical_bump or assert_seeds_with_bump calls before this builder. Plain invoke is for account types whose fields may all remain zero after the discriminator is written.

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 token_program = *self.token_program.address();
let decimals = self
	.mint_a
	.as_token_mint_for_program(&token_program)?
	.decimals();
drop(
	self.mint_b
		.as_token_mint_for_program(&token_program)?,
);
drop(self.maker_ata_a.as_associated_token_account(
	self.maker.address(),
	self.mint_a.address(),
	&token_program,
)?);
token::instructions::TransferChecked::new(
	self.maker_ata_a,
	self.mint_a,
	self.vault,
	self.maker,
	args.amount_a.into(),
	decimals,
)
.invoke_with_program(&token_program)?;
}

Pina’s token feature provides typed CPI instruction builders. Construct the instruction with new() and invoke it through the validated token program account. The shared loader validates either the fixed SPL Token layout or a complete Token-2022 extension layout according to the selected program.

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:

  1. Transfer token B from taker to maker (authorized by the taker’s signature).
  2. Transfer token A from vault to taker (authorized by the escrow PDA via invoke_signed).
  3. Close the vault account and return rent to the maker.
  4. 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)
		};
		let token_program = *self.token_program.address();
		let decimals_a = self
			.mint_a
			.as_token_mint_for_program(&token_program)?
			.decimals();
		let decimals_b = self
			.mint_b
			.as_token_mint_for_program(&token_program)?
			.decimals();

		// Verify the escrow is the PDA for the maker and seed, using the
		// stored bump field (avoids re-deriving the canonical bump on-chain).
		EscrowState::assert_seeds(self.escrow, &maker, u64::from(seed), &ID)?;

		// Transfer token B: taker -> maker
		token::instructions::TransferChecked::new(
			self.taker_ata_b,
			self.mint_b,
			self.maker_ata_b,
			self.taker,
			u64::from(amount_b),
			decimals_b,
		)
		.invoke_with_program(&token_program)?;

		// Transfer token A: vault -> taker (PDA-signed)
		let escrow_seeds = EscrowState::seeds(&maker, u64::from(seed)).with_bump(bump);
		let escrow_signer = escrow_seeds.to_signer();
		let signers = [escrow_signer.as_signer()];

		let vault_amount = self
			.vault
			.as_associated_token_account(
				self.escrow.address(),
				self.mint_a.address(),
				&token_program,
			)?
			.amount();
		token::instructions::TransferChecked::new(
			self.vault,
			self.mint_a,
			self.taker_ata_a,
			self.escrow,
			vault_amount,
			decimals_a,
		)
		.invoke_signed_with_program(&signers, &token_program)?;

		// Close vault and escrow
		token::instructions::CloseAccount::new(self.vault, self.maker, self.escrow)
			.invoke_signed_with_program(&signers, &token_program)?;

		// Clear the raw backing bytes while closing.
		self.escrow.close_account_zeroed(&ID, self.maker)
	}
}
}

The PDA signer is constructed from the same seeds used to derive the escrow address. invoke_signed_with_program passes these seeds to the selected SPL Token program so the runtime can verify the PDA signature.

close_account_zeroed verifies that ID owns the escrow, zeroes its data, transfers the remaining lamports to the maker, and closes the account. The non-zeroing close_with_recipient fails the require_zeroed_before_close lint unless the whole data buffer is cleared first with self.escrow.try_borrow_mut()?.fill(0);.

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((program_id, accounts))?.process(data)
			}
			EscrowInstruction::Take => {
				TakeAccounts::try_from((program_id, 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_build_expected_seed_arrays() {
		let maker = Address::new_from_array([3u8; 32]);
		let seed = PodU64::from(42);
		let bump = 7u8;

		let seeds = EscrowState::seeds(&maker, u64::from(seed));
		assert_eq!(seeds.as_slices().len(), 3);

		let seeds_with_bump = seeds.with_bump(bump);
		assert_eq!(seeds_with_bump.as_slices().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 token feature 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 parity example programs (ports of Anchor’s own test suite, without the anchor_ prefix) that demonstrate direct parity with Anchor’s behavior. 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::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 manual match in the entrypoint.
  • No Context<T>. The entrypoint receives &mut [AccountView], #[derive(Accounts)] maps that mutable slice into typed fields, and the processor receives raw data: &[u8].
  • Constraints are code, not attributes. Validation happens inside process via 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 attributePina 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 canonical CreateProgramAccount { ... }.invoke::<MyData>()
#[account(constraint = expr)]Write the check directly in process and return an error
Account<'info, T>account.as_account::<T>(&owner)? or account.as_account_mut::<T>(&owner)?

Pina’s assertion methods return the same reference type they receive, so shared chains stay shared and mutable chains stay mutable. Typed loaders perform their own validation; do not precede them with assert_type:

#![allow(unused)]
fn main() {
let mut counter = self.counter.as_account_mut::<CounterState>(&ID)?;
}

For a fixed account with a stored #[pda(bump = ...)], prefer its generated Type::load_pda or Type::load_pda_mut method. These methods also validate the PDA address. Keep assert_type for the narrower case where the handler only validates an existing fixed account and never accesses typed fields.

See examples/counter_program for a complete PDA creation and validation example, and examples/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 PinaPod-validated zero-copy layouts. Fixed accounts reserve the full capacity of bounded collections, while compact accounts place supported collections in variable-length tails.

Anchor typePina schema typeNotes
PubkeyAddressBoth store 32 bytes
u64, u32, u16, i64Same native typePinaPod generates little-endian alignment-one storage
boolboolPinaPod generates a checked one-byte representation
StringString<N>Choose a byte capacity; fixed accounts reserve all N bytes
Vec<T>Vec<T, N>Choose an element capacity; compact accounts store only active elements
Option<T>Option<T>T must have a supported fixed representation

PinaPod’s generated storage wrappers keep every field alignment one and provide recursive validation. Direct Pod* wrappers still convert to and from native types with From:

#![allow(unused)]
fn main() {
// Creating Pod values
let value = PodU64::from(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::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,
		})
	}
}
}

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.

GoalRecommended layout
Keep layout minimal and zero-copy while staying explicitCurrent 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 typesUse u8 (default) discriminator width.
You need more than 256 route variantsUse u16 / u32 / u64 by setting #[discriminator(primitive = ...)].
Avoid schema migrations across existing serialized dataKeep existing field order and discriminator values; only append fields.

Raw discriminator width by use-case

WidthMax variantsStorage cost (bytes)Recommended when
u82561Most programs and instructions
u1665,5362Medium-large routing tables and explicit version partitioning
u324,294,967,2964Very large enums, rarely needed
u6418,446,744,073,709,551,6168Legacy interoperability shims or reserved growth
  • Discriminator width only affects the first field bytes.
  • Widths above 8 are rejected at macro expansion time.
  • Wider discriminators improve variant space, but increase CPI payload and account rent by the exact number of bytes.
  • These widths describe discriminators only. The migration version envelope is a separate setting and accepts u8, u16, or u32 — never u64.

Discriminators and ABI migrations

ChangeCompatibility impact
Add a new discriminator variantBackward-compatible; existing routes keep their identity
Change an existing discriminator valueBreaking for every historical byte slice
Change a migration-aware account or enveloped instruction payloadCompatible only when the checked-in history has an adjacent transition
Change a published versioned event schemaCompatible; a new version is appended and clients decode each version with its own schema
Change a published snapshot-only instruction payloadBreaking; create a new instruction discriminator
Append optional accounts to an instruction routeCompatible when the existing positional list remains an identical prefix
Reorder, remove, or escalate an instruction slotBreaking; create a new instruction discriminator
Change the migration version width after releaseBreaking for every enveloped wire contract

Add migrations to an account, instruction, or event attribute to opt one contract into a framework-owned version field, or opt whole contract kinds in when you record the history:

pina migrations create --auto true # or --auto accounts,events,instructions

--auto accepts true, false, or a comma-separated list of accounts, events, and instructions. pina migrations create records the policy in migrations/manifest.json, its only home, and snapshots every contract of the listed kinds; a later run without the flag keeps the recorded policy. pina.toml holds no migration policy. Macros read the policy from the manifest, so a new struct still fails the build with “run pina migrations create” until it has a snapshot. Once a policy is recorded, create also scaffolds a build.rs emitting cargo:rerun-if-changed=migrations/manifest.json, so flipping the policy re-expands every contract without editing source. Add migrations = false to keep one contract out of an auto policy; removing an envelope the manifest already records is an error instead of a silent opt-out, because stripping an envelope is itself a wire-format change.

The policy gives accounts and events the version field. It records each instruction as a snapshot without one: the payload keeps its [discriminator][payload] wire format, and the build fails when the struct drifts from the snapshot. Add migrations to an #[instruction] to give that instruction the version field and adjacent transitions instead; the #[discriminator(entrypoint)] dispatcher then converts an older payload to the current layout before the handler runs.

Pina places the version field immediately after the discriminator. The accepted encodings are u8, u16, and u32; u8 is the default and the recommended choice. Versions are tracked per contract, not per program: each account, instruction, and event owns an independent history that starts at version 0, so u8 gives every contract its own 255-version budget. Rewriting one contract 255 times is not a realistic outcome, and the narrower field costs one byte in every enveloped account. Choose a wider encoding with pina migrations create --version-type u16 (or u32) before the first release only when you expect a single contract to exceed 255 versions. The width is program-wide, recorded as versionType in the manifest, and freezes at the first published release: after that the flag fails instead of widening it. Any other value, including u64, is rejected with an error naming the supported widths. Discriminator width is a separate setting, and that one does support u64.

Run pina migrations create before a release. Pina updates the replaceable draft when the current version is unpublished. After pina deploy records a non-local publication, the next schema change creates a new version, with an adjacent transition for an account or an enveloped instruction; the payload of a published snapshot-only instruction cannot change. Normal builds run pina migrations check and fail on drift, incomplete manual transitions, or changed published code.

An old instruction can omit only newly appended optional accounts. Pina does not synthesize signers, writable privileges, PDAs, or required accounts. Any change to an existing process slot requires a new discriminator.

Historical events are immutable, so Pina versions events instead of migrating them. The program emits only the current version. Generated clients decode each earlier version with its own schema, as a separate <Event>V<n> event, and a log record whose version no generated event describes fails instead of being misread.

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/custom_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: u64,
	pub label: [u8; 8],
}

// Emit the event to the `Program data:` transaction log.
MyEvent::emit(|event| {
	event.data = 5;
	event.label = *b"hello\0\0\0";
	Ok(())
})?;
}

Pina events are native PinaPod schemas with explicit discriminators, like accounts and instructions. The macro generates a validated MyEventZc storage view plus an emit helper. emit builds the [discriminator][payload] record, with the schema version after the discriminator when the event opts into migrations, through the same validated path as try_from_bytes and writes it to the transaction log as a Program data: line — the record the generated Rust, TypeScript, and Dart decoders read. Pina does not expose an object-representation to_bytes() method; use emit so the framework owns and initializes the output buffer.

emit needs Pina’s logs feature. Enable it in your program’s manifest:

pina = { version = "...", features = ["logs", "derive"] }

See examples/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::instructions::TransferChecked::new(
	self.from,
	self.mint,
	self.to,
	self.authority,
	amount,
	decimals,
)
.invoke_with_program(self.token_program.address())?;
}

Pina’s CPI helpers (enabled with features = ["token"]) are typed instruction builders. Construct one with new() and call .invoke_with_program() or .invoke_signed_with_program() 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:
CreateProgramAccount {
	account: self.my_account,
	payer: self.payer,
	owner: &ID,
	seeds,
}
.invoke::<MyData>()?;

// For regular accounts:
CreateAccount {
	from: self.payer,
	to: self.my_account,
	space: MyData::SIZE as u64,
	owner: &ID,
}
.invoke()?;
}

Space is automatically computed from MyData::SIZE for the PDA builder. For CreateAccount you pass the size explicitly. In both cases, rent-exemption lamports are calculated and transferred automatically.

CreateProgramAccount derives the canonical bump itself. Use CreateProgramAccountWithBump only when the instruction intentionally supplies a bump; that explicit-bump variant verifies the supplied value is canonical before creating the account.

Use invoke::<MyData>() when the discriminator plus zeroed fields is already a valid complete value. Use invoke_with when creation must set fields before final PinaPod validation. If the payer also needs PDA signer seeds, use invoke_signed_with::<MyData>(signers, initialize).

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((program_id, 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


  1. Replace anchor_lang::prelude::* with use pina::*.
  2. Convert #[account] structs from Borsh to Pod types (PodU64, PodBool, Address, fixed-size arrays).
  3. Define explicit #[discriminator] enums for instructions and accounts.
  4. Replace #[account(...)] constraint attributes with validation chain calls in process.
  5. Replace Context<T> with #[derive(Accounts)] structs and ProcessAccountInfos.
  6. Replace CpiContext patterns with Pina’s typed CPI instruction builders.
  7. Replace #[error_code] with #[error] and explicit numeric codes.
  8. Replace #[event] + emit! with Pina’s Pod-based event structs.
  9. Add #![no_std] and the bpf-entrypoint feature gate.
  10. Port TypeScript tests to Rust using mollusk-svm or native unit tests.

Security Model

Pina’s safety posture is built around explicit validation and predictable state transitions.

The Security Lints page maps these invariants to the compile-time checks applied to every repository example and secure security fixture.

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.

Discriminators and ABI migrations

ChangeCompatibility impact
Add a new discriminator variantBackward-compatible; existing routes keep their identity
Change an existing discriminator valueBreaking for every historical byte slice
Change a migration-aware account or enveloped instruction payloadCompatible only when the checked-in history has an adjacent transition
Change a published versioned event schemaCompatible; a new version is appended and clients decode each version with its own schema
Change a published snapshot-only instruction payloadBreaking; create a new instruction discriminator
Append optional accounts to an instruction routeCompatible when the existing positional list remains an identical prefix
Reorder, remove, or escalate an instruction slotBreaking; create a new instruction discriminator
Change the migration version width after releaseBreaking for every enveloped wire contract

Add migrations to an account, instruction, or event attribute to opt one contract into a framework-owned version field, or opt whole contract kinds in when you record the history:

pina migrations create --auto true # or --auto accounts,events,instructions

--auto accepts true, false, or a comma-separated list of accounts, events, and instructions. pina migrations create records the policy in migrations/manifest.json, its only home, and snapshots every contract of the listed kinds; a later run without the flag keeps the recorded policy. pina.toml holds no migration policy. Macros read the policy from the manifest, so a new struct still fails the build with “run pina migrations create” until it has a snapshot. Once a policy is recorded, create also scaffolds a build.rs emitting cargo:rerun-if-changed=migrations/manifest.json, so flipping the policy re-expands every contract without editing source. Add migrations = false to keep one contract out of an auto policy; removing an envelope the manifest already records is an error instead of a silent opt-out, because stripping an envelope is itself a wire-format change.

The policy gives accounts and events the version field. It records each instruction as a snapshot without one: the payload keeps its [discriminator][payload] wire format, and the build fails when the struct drifts from the snapshot. Add migrations to an #[instruction] to give that instruction the version field and adjacent transitions instead; the #[discriminator(entrypoint)] dispatcher then converts an older payload to the current layout before the handler runs.

Pina places the version field immediately after the discriminator. The accepted encodings are u8, u16, and u32; u8 is the default and the recommended choice. Versions are tracked per contract, not per program: each account, instruction, and event owns an independent history that starts at version 0, so u8 gives every contract its own 255-version budget. Rewriting one contract 255 times is not a realistic outcome, and the narrower field costs one byte in every enveloped account. Choose a wider encoding with pina migrations create --version-type u16 (or u32) before the first release only when you expect a single contract to exceed 255 versions. The width is program-wide, recorded as versionType in the manifest, and freezes at the first published release: after that the flag fails instead of widening it. Any other value, including u64, is rejected with an error naming the supported widths. Discriminator width is a separate setting, and that one does support u64.

Run pina migrations create before a release. Pina updates the replaceable draft when the current version is unpublished. After pina deploy records a non-local publication, the next schema change creates a new version, with an adjacent transition for an account or an enveloped instruction; the payload of a published snapshot-only instruction cannot change. Normal builds run pina migrations check and fail on drift, incomplete manual transitions, or changed published code.

An old instruction can omit only newly appended optional accounts. Pina does not synthesize signers, writable privileges, PDAs, or required accounts. Any change to an existing process slot requires a new discriminator.

Historical events are immutable, so Pina versions events instead of migrating them. The program emits only the current version. Generated clients decode each earlier version with its own schema, as a separate <Event>V<n> event, and a log record whose version no generated event describes fails instead of being misread.

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(&ID, recipient) verifies that ID owns the account, transfers its lamports, and closes the account handle. It does not zero or resize account data.
  • When stale bytes must be invalidated, use close_account_zeroed(&ID, recipient) or CloseAccountZeroed { account, recipient, program_id: &ID }.invoke(). When the close must stay separate, clear the whole data buffer with account.try_borrow_mut()?.fill(0); before close_with_recipient(&ID, recipient).
  • The account-resize feature only affects realloc helpers; it does not change close semantics.

Best practices

  • Always call assert_signer() before trusting authority accounts
  • Use Pina’s token loaders directly because they delegate canonical owner and layout validation to the corresponding checked upstream parser before returning typed state
  • Use as_associated_token_account() when reading a canonical ATA because it validates the runtime owner, derived address, stored current authority, and stored mint together; enforce state, delegate, close-authority, and extension policy separately
  • Skip assert_associated_token_address() when the same account feeds an associated_token_account::instructions::Create or CreateIdempotent CPI, because that program derives the identical seeds and returns InvalidSeeds on a mismatch; reserve the assertion for validation-only paths that never reach an ATA instruction
  • Create accounts through the typed creation builders, which reject targets whose storage is not zeroed with AccountAlreadyInitialized
  • Use invoke_with or invoke_signed_with when fixed-account creation must establish nonzero values before final PinaPod validation
  • Use invoke_with_bump or invoke_signed_with_bump when the account stores its own bump, and store the bump they pass to the initializer; a stored bump that does not derive the account’s address makes every later load_pda fail with InvalidSeeds
  • Use generated load_pda or load_pda_mut when a fixed stored-bump PDA handler needs a typed guard, so recursive content and the PDA address are validated once
  • Use generated with_stored_bump_pda (the pre-split name with_pda still works but is deprecated) when a compact stored-bump PDA handler needs a compact view and the address is already established, so the layout and stored-bump PDA address are validated during the same borrow
  • Use generated load_checked_pda / load_checked_pda_mut instead of load_pda*, or with_checked_pda instead of with_stored_bump_pda, when an untrusted caller chooses which account the handler loads; they search for the canonical bump and so reject a shadow account created at a noncanonical bump, which a stored-bump check alone cannot detect
  • Validate dynamic program accounts with Pina’s assert_program() before explicitly unverified CPI invocations; static and self-verifying CPI APIs need no redundant assertion
  • Use as_account::<T>() or as_account_mut::<T>() when a handler needs fixed-account fields; these guard-backed loaders check the owner, discriminator, exact size, and nested values
  • Reserve assert_type::<T>() for validation-only paths that do not need typed fields, and never treat it as proof for a later raw cast
  • Use send_owned(&ID, amount, recipient) for direct lamport debits; it verifies that the program owns the sender before mutation
  • Use close_account_zeroed(&ID, recipient) or CloseAccountZeroed { account, recipient, program_id: &ID }.invoke() when stale account bytes must be invalidated before close; when the close must stay separate, clear the whole buffer with account.try_borrow_mut()?.fill(0); before close_with_recipient(&ID, recipient)
  • Use CreateProgramAccount or CreateCompactProgramAccount for canonical PDA creation; their explicit-bump variants also reject noncanonical bumps without separate seed assertions
  • Reserve CreateProgramAccountWithUncheckedBump for seed namespaces that already bind uniqueness; it checks the supplied bump derives the account’s address but does not prove it canonical
  • Keep assert_seeds() / assert_canonical_bump() for validation-only paths that are not immediately followed by a checked creation builder
  • Give each account type its own seed namespace so PDAs cannot collide across account types

Stored-bump PDA verification

load_pda, load_pda_mut, and with_stored_bump_pda re-derive the account’s address from the bump stored in its data with sha256 (pina::is_derived_address) instead of the sol_create_program_address syscall, which saves about 1,350 compute units per load. The syscall adds one check the hash does not: that the result lies off the ed25519 curve. That check is redundant for any account these loaders accept.

  • Checks run in order. Each loader first requires the program to own the account and its data to pass the type’s discriminator and layout checks, so the program itself initialized the account.
  • Only a valid program address can get that far. The program can only create an account at a seed-derived address through invoke_signed, and the runtime signs only for an off-curve address. Pina’s creation builders cannot adopt an account someone else assigned to the program, because the system program’s allocate and assign refuse an account the system program does not own. So the stored bump already derived a valid program address.
  • The skipped check adds nothing here. The only address it would additionally reject is an on-curve address equal to the hash, for which no one can derive a private key.
  • Seed lengths are still checked. sha256 concatenates the seeds, so a 33-byte seed hashes exactly like a 32-byte seed followed by a 1-byte one, which can be a valid program address. create_program_address rejects any seed longer than MAX_SEED_LEN, and is_derived_address does the same before hashing. A seed of constant or fixed-size length, which every generated loader passes, folds the check away.

The PDA creation builders check the target address the same way before the create-account CPI. There the runtime performs the curve check itself: the allocation signs for the address through invoke_signed with the same seeds and bump, and the runtime refuses to sign for an on-curve address, failing the instruction with “Could not create program address with signer seeds”. counter_program’s Surfpool suite proves it with a bump whose derived address is on the curve: the program’s check accepts that address, the runtime rejects the signature, and no account is created. The builders still reject a seed longer than MAX_SEED_LEN themselves, with InvalidSeeds, as they did when they called create_program_address.

The runtime’s on-curve rejection aborts execution during signer derivation. It does not return Err(ProgramError::InvalidSeeds) to a Rust caller that catches creation errors. This changes error recoverability for CreateProgramAccountWithUncheckedBump and AllocateAccountWithNonCanonicalBump; it does not let an invalid PDA be created. Instructions that need to recover from an invalid bump must call create_program_address with the seeds including the bump before invoking the builder. That optional preflight returns a catchable error and adds a curve check. Instructions that propagate creation failures can keep the cheaper default path. Canonical creation builders already prove a valid bump before CPI.

Keep create_program_address (through assert_seeds_with_bump) for a bump a caller supplies when no invoke_signed follows, and the canonical loaders and builders (load_checked_pda, load_checked_pda_mut, with_checked_pda, CreateProgramAccount) when the bump must be the highest valid one. Both still check the curve.

Dispatch-first account parsing

dispatch_entrypoint! reads the instruction data through the pointer the loader passes since SIMD-0321, then walks the serialized accounts itself instead of through pinocchio’s deserializer. That walk is the same trust boundary: it reads the loader’s input region, which the runtime lays out and bounds.

  • The raw input stays inside pina. The entrypoint wraps it in EntrypointInput, which only pina can create and whose parse methods take it by value, so generated routers contain no unsafe code and cannot walk the input twice. A second walk after a handler resized an account would read the changed data length and step outside the records.
  • The walk matches pinocchio’s. A property test in pina::entry serializes random account layouts, including duplicates and varied data lengths, and requires the walk to find the same views as pinocchio::entrypoint::deserialize and to leave the input byte for byte as it does. The unit and property tests also run under Miri.
  • It rejects a duplicate marker the runtime never writes. A marker that does not name an earlier slot fails with InvalidAccountData instead of copying an uninitialized view.
  • Every check still runs, in the same order, except that account counts come first. The program-ID and discriminator checks still run before any account is read, and the routed struct and handler run unchanged. An instruction with more accounts than its struct reads now fails with TooManyAccountKeys, and one with fewer than a fixed-length struct reads with NotEnoughAccountKeys, before any per-account check, where the struct’s parser could report an earlier field’s failure first.
  • It needs SIMD-0321. Without the feature the second entrypoint argument is undefined. It is active on every public cluster, in Surfpool, and in Mollusk.

Content validation with PinaPod

Pina’s zero-copy account model is built on PinaPod. Its generated storage view makes validation load-bearing: PinaAccount::try_from_bytes and as_account reject noncanonical booleans, invalid UTF-8, overlength vector prefixes, invalid option tags, invalid active nested values, and invalid enum discriminants before returning a reference.

The #[account] macro uses the native struct only as a schema and derives PinaPod. For Account, PinaPod generates AccountZc; loaders return that companion, not a reference to the native schema. PinaAccount::validate_account_data checks the discriminator, exact size, and every fixed field. Compact loaders also check every tail offset and active length.

Unit enums

Unit enums with explicit discriminants can derive PinaPod. PinaPod generates an EnumZc companion that stores raw bytes and validates the discriminant before converting it to the native enum. Pina’s audited #[account] grammar does not accept arbitrary custom enums, so this form applies to direct PinaPod schemas and advanced manual PinaAccount implementations.

Inactive capacity

PinaPod initializes the full capacity of fixed strings, vectors, and options. Shortening or clearing a value also zeroes the removed payload, so stale application data does not remain in inactive capacity. Pina still exposes validated field accessors rather than a byte slice over an in-memory schema or storage view.

Compact patches clear bytes removed by a tail replacement. Typed creation and update builders accept the account’s exact generated patch through PinaCompactPatch; an unrelated PinaPodPatch implementation cannot bypass that boundary. UpdateResizableAccount preflights the structural patch and target size before moving rent or changing account bytes. A failed preflight leaves both data and lamports unchanged. With the validation feature, application rules run on the completed representation after the patch is written. Propagate every update error so Solana rolls back the bytes and any earlier rent movement.

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.

These framework guarantees do not validate an application’s economic design. Use the Production Readiness gate before deploying an asset-bearing program.

PinaPod integration and safety boundary

Architecture

Pina uses PinaPod’s native-schema model:

#![allow(unused)]
fn main() {
#[account(discriminator = ProfileAccountType)]
pub struct ProfileState {
	pub bump: u8,
	pub name: pina::String<32>,
	pub tags: pina::Vec<u64, 8>,
	pub active: bool,
	pub note: Option<String<64>>,
}
}

The source struct is a native schema. PinaPod generates ProfileStateZc, whose fields use alignment-one storage representations. Pina’s account loaders validate the runtime byte slice and return a borrow of that generated view. Callers use the generated field accessors rather than treating the native struct as account memory.

Why there is no to_bytes()

Pina accepts bytes through a validation boundary:

initialized runtime bytes -> PinaPod validation -> borrowed TypeZc view

It does not expose this operation:

schema or TypeZc object representation -> borrowed byte slice

The first direction validates external storage. The second would make Rust object representation part of Pina’s API and would weaken future layout changes.

Initialization and clearing

Fixed account, instruction, and event schemas have an initialize helper. The caller supplies an exact mutable byte slice. PinaPod zeros the slice, lets the caller set the generated view, validates the finished value, and zeros the slice again if initialization fails.

let mut storage = vec![0u8; ProfileState::SIZE];
let profile = ProfileState::initialize(&mut storage, |profile| {
	profile.bump = bump;
	profile.name.try_set("alice")?;
	profile.tags.try_set([1, 2, 3])?;
	profile.active.set(true);
	Ok(())
})?;

Fixed containers initialize their complete capacity. Shortening or clearing a string, vector, or option zeros the removed payload. Compact patches also clear bytes removed from a tail before Pina releases or shrinks the backing account storage.

Fixed account creation

CreateProgramAccount and CreateProgramAccountWithBump expose the same initialization boundary. The canonical builder can pass its derived bump directly into the initializer:

CreateProgramAccount {
	account,
	payer,
	owner: &ID,
	seeds,
}
.invoke_with_bump::<ProfileState>(|profile, bump| {
	profile.bump = bump;
	profile.name.try_set("alice")?;
	profile.tags.try_set([1, 2, 3])?;
	profile.active.set(true);
	Ok(())
})?;

invoke and invoke_signed use an empty initializer. They succeed only when the discriminator plus an otherwise all-zero representation is valid. invoke_with and invoke_signed_with configure the generated view before final validation, so callers can establish required nonzero values without exposing a partially initialized typed account between creation and mutation.

The initializer returns Result<(), PinaPodError>. PinaPod clears the complete destination before the closure, Pina writes the discriminator, and PinaPod validates the finished value once. A closure or validation error clears the bytes again and becomes ProgramError::InvalidAccountData. In an on-chain instruction, returning that error also causes Solana to roll back the account-creation CPI with the rest of the transaction.

Advanced manual PinaAccount implementations can contain a storage enum whose valid discriminants start above zero. Such a type must use invoke_with and set the enum before final validation. Pina’s closed #[account] grammar does not accept arbitrary custom enum fields, so this is not an extension of the audited macro grammar.

Fixed PDA loading

For a fixed account with a stored PDA bump, Type::load_pda and Type::load_pda_mut validate the owner, size, discriminator, active nested values, and derived account address before exposing a guard. The mutable form also requires writability. This is one validation boundary: it does not call assert_type, generated assert_seeds, and as_account_mut as three separate passes.

The loaded guard still owns the runtime account-data borrow. End its scope or call drop before a CPI that may access the same account. Pina’s deny_account_borrows_across_cpi lint recognizes guards returned by load_pda_mut.

For a non-PDA fixed account, use as_account or as_account_mut to establish the same guard-backed boundary. Keep assert_type for validation-only operations that never need typed fields. Because assert_type releases its borrow before returning, it is not proof that a later raw cast is safe.

Compact PDA loading

For a compact account with a stored PDA bump, Type::with_pda validates the owner, discriminator, size, every active tail, and the address the stored bump derives before it runs the closure. It performs a single derivation, not a canonical bump search, so it accepts any account the stored bump derives — including a shadow account created at a noncanonical bump. Type::with_checked_pda searches for the canonical bump and rejects that shadow account, at the cost of the search. The runtime borrow guard remains active for the closure in both cases and is released when the closure returns.

Use Type::with_pda when the handler needs compact data and the address is already established. Use Type::with_checked_pda when an untrusted caller chooses which account the handler loads, or when the program must be certain that exactly one address exists for the seeds. Do not call assert_compact_type, generated assert_seeds, and with_compact_account first. Those calls repeat compact parsing and PDA derivation. Keep assert_compact_type or generated assert_seeds for validation-only code.

Compact account creation

Compact creation uses a generated patch. The canonical builder accepts the patch at invocation time, including a factory that receives the derived bump:

CreateCompactProgramAccount {
	account,
	payer,
	owner: &ID,
	seeds,
	space: Journal::HEADER_SIZE,
}
.invoke_with_bump::<Journal, _>(|bump| JournalPatch::new().bump(bump))?;

The patch is the typed initialization plan. The builder validates space, allocates the account, applies the patch, and writes the discriminator. Pass JournalPatch::new() to invoke for an all-zero, empty-tail default.

Generated clients allocate zeroed instruction buffers, configure private generated views, validate them, and move the buffers into Solana instructions. They do not expose a general-purpose to_bytes() method.

Ownership of unsafe code

PinaPod owns its unsafe traits and byte-to-view pointer conversions. Pina’s schema macros derive PinaPod; Pina does not provide a second generic cast.

Pina is responsible for:

  • exact fixed length or valid compact bounds;
  • the expected discriminator;
  • the runtime borrow-guard lifetime;
  • owner, signer, writable, and PDA validation;
  • ending a data borrow before a runtime resize;
  • changing rent and bytes only after compact patch preflight succeeds.

PinaPodFixed is an unsafe trait. A manual implementation must uphold the complete PinaPod contract for every input byte slice.

Verification requirements

The integration keeps regression coverage at each boundary:

  • compile-time tests pin the generated schema, view, and patch APIs;
  • negative tests reject invalid booleans, enum values, UTF-8, prefixes, tags, capacities, and nested active elements;
  • fixed-create tests prove that an invalid all-zero default fails, invoke_with can establish a required nonzero value, and failed initialization clears the destination;
  • account loader tests keep runtime borrow guards alive;
  • Miri exercises initialization, aliasing, inactive capacity, and removed safe accessors;
  • generated-client tests assert exact bytes and reject over-capacity input;
  • IDL drift tests compare Rust, TypeScript, and Dart layouts;
  • SBF tests exercise account creation, growth, shrink, rent adjustment, and failed-update rollback.

Pina validates and borrows initialized external bytes. It never treats an arbitrary in-memory schema value as raw account bytes.

Security Lints

The Pina logo: a low-poly origami pineapple

pina_lints is Pina’s self-contained replacement for the previous Dylint setup: every security, performance, and IDL lint that Pina ships lives in this one importable crate, so the lints are built into Pina instead of being distributed as separate Dylint libraries. They turn repository security conventions into compiler diagnostics and are intended to run during normal development and CI.

The crate keeps the Dylint authoring shape — each lint lives in its own module under lints and declares itself with declare_late_lint! or declare_pre_expansion_lint! — but no lint registers itself; registration is centralized in register_all_lints. The crate builds as both a library and a cdylib that exports the Dylint-compatible register_lints symbol, so a Dylint driver can still load it as a single library. It also ships the bundled pina_lint_driver binary, a rustc wrapper with every lint statically linked; pina lint runs it as RUSTC_WORKSPACE_WRAPPER, so it needs no external lint tooling.

The lints complement tests and audits; they do not prove that a program’s economic design is safe. Every path-sensitive lint documents the approximation it uses so findings can be reviewed with the right expectations.

Crates.io Docs.rs CI Coverage License

Installation and execution

Run the catalog shipped with your installed Pina CLI:

pina lint
# Apply machine-applicable suggestions, then inspect the diff.
pina lint --fix

pina lint resolves a pina_lint_driver built for the project’s active toolchain — bundled next to the CLI, cached from a previous run, or downloaded from the Pina release matching the CLI — then invokes cargo with the driver as RUSTC_WORKSPACE_WRAPPER. Cargo calls the driver with the arguments it would have passed to rustc; the driver registers every lint compiled into this crate, and compilation continues normally. Cargo preserves and nests an existing RUSTC_WRAPPER, such as sccache, outside the lint driver. Because the lints are statically linked into the driver, no external lint tooling is downloaded or installed. The driver reads a few environment variables: PINA_LINT_NO_DEPS skips dependency crates, PINA_LINT_LEVELS forwards configured lint levels to rustc (see Configuring lint levels), PINA_LINT_ONLY restricts linting to a single lint, and PINA_LINT_LIST prints the lint catalog instead of compiling. PINA_LINT_NO_DEPS, PINA_LINT_LEVELS, and PINA_LINT_ONLY are recorded in dep-info, so changing them invalidates cargo’s cached check results.

The crate itself is nightly-only: the lint passes and the driver link against the Rust compiler’s unstable rustc_private crates. Because those crates are not compatible across nightlies, a driver only loads against the exact compiler revision it was built with, so pina lint negotiates a driver for whatever toolchain the project activates rather than requiring one pinned nightly. The crate is published to crates.io; the CLI builds the driver from that release only when pina lint --build-driver is requested for a nightly Pina publishes no prebuilt driver for. Run pina doctor to see the active toolchain, the resolved driver, and the remedy when none resolved.

Pina contributors still run the in-workspace driver when changing a lint:

devenv shell -- security:pina-lint

security:pina-lint is the authoritative gate. It builds the workspace’s pina_lint_driver binary and runs cargo with RUSTC_WORKSPACE_WRAPPER pointing at it, discovering every package under examples/ and every security/*/secure fixture, then checks each one in the driver’s no-deps mode with --locked. Insecure fixtures are intentionally excluded because they preserve examples of unsafe patterns.

Importing the lints

Every lint constant and pass is public:

use pina_lints::lints::require_consistent_token_program::REQUIRE_CONSISTENT_TOKEN_PROGRAM;
use pina_lints::lints::require_consistent_token_program::RequireConsistentTokenProgram;

Tooling that needs to validate lint names or enumerate the catalog can read pina_lints::LINT_NAMES, which lists every lint in the crate in catalog (alphabetical) order.

Configuring lint levels

Lint levels are configured in the project’s pina.toml under the [lints] table. Each entry maps a lint name to allow, warn, or deny; lints that are not listed use their built-in default level (the “Level” column in the catalog below). The Pina CLI reads the table and passes the result to pina_lint_driver through the PINA_LINT_LEVELS environment variable; the driver forwards each level to rustc as an --allow, --warn, or --deny argument.

[lints]
deny_heap_allocations_in_onchain_instruction_handlers = "deny"
deny_colliding_account_discriminators = "deny"
require_explicit_discriminators_and_seed_namespaces = "allow"

Deny-level security lints should not be disabled at crate scope; see the suppression policy below.

Complete lint catalog

LintLevelPrimary invariant
require_program_check_before_cpidenyCPI targets are authenticated
deny_heap_allocations_in_onchain_instruction_handlerswarnOn-chain handlers avoid unbounded allocation cost
require_writable_before_account_resizedenyResize targets are writable
require_zeroed_before_closedenyClosed account data is invalidated
require_sysvar_assert_before_sysvar_usedenySysvar accounts cannot be substituted
require_type_assert_before_zero_copy_castdenyRaw account casts use guard-backed typed loading
require_reason_for_duplicate_remaining_accountsdenyDuplicate mutable remaining accounts are justified
deny_unchecked_remaining_mutdenyDirect mutable remaining accounts reject aliases
require_canonical_bump_before_pda_writedenyPDA namespaces use canonical bumps
deny_account_borrows_across_cpidenyMutable data guards end before CPI
deny_colliding_account_discriminatorsdenyAccount discriminator values stay globally unique
deny_unused_account_borrow_guardswarnUnread borrow guards are discarded immediately
require_consistent_token_programdenyToken validation and CPI share one program identity
require_explicit_token_2022_extension_policydenyToken-2022 extensions are explicitly allow-listed
require_post_cpi_balance_reloaddenyToken CPI destinations are reloaded after the CPI
require_checked_asset_arithmeticdenyEconomic arithmetic fails on overflow/underflow
require_guarded_full_balance_drainwarnFull-balance drains are gated by a guard
require_bounded_remaining_accountsdenyCaller-controlled account work has a visible bound
require_idl_root_to_define_one_program_idwarnIDL roots expose exactly one program ID
require_canonical_instruction_dispatch_for_idlwarnEntrypoints use discoverable instruction dispatch
require_explicit_discriminators_and_seed_namespaceswarnExamples expose type and PDA namespaces

Security and correctness reference

require_program_check_before_cpi

Detects invoke_with_unverified_program() and invoke_signed_with_unverified_program() calls without a proof for the exact dynamic program argument. Pina’s assert_program(), assert_address(), or assert_addresses() must compare against a const, an immutable static whose type contains no interior mutability, or an unmodified local alias of one and succeed on every continuing path to the invocation. A value supplied through instruction data is not authentication: comparing two attacker-controlled values proves consistency, not identity. A const or static whose type contains UnsafeCell, such as Mutex<Pubkey>, a struct wrapping one, or a const of reference type aliasing one, can be rewritten at runtime and therefore does not establish a proof; a dynamic expected ID needs a narrowly scoped lint allowance with documented authentication. Enforce the assertion with ?, unwrap(), or expect(). Failure-side map_err() and inspect_err() adapters are also accepted before extraction. Discarding the Result or inspecting failure does not establish a proof. Success-side map(), and_then(), and inspect() adapters do not establish a proof because their callbacks can replace the validated binding before execution continues. Assignments, mutable borrows, &mut self method calls, and closures that may replace a captured account or expected-ID alias invalidate the proof. Prefer assert_program() for an explicit program account because it checks both the address and the executable flag.

#![allow(unused)]
fn main() {
token_program.assert_program(&token::ID)?;
transfer.invoke_with_unverified_program(token_program.address())?;
}

Pinocchio Token’s .invoke_with_program() and .invoke_signed_with_program() methods call Program::verify() themselves, so they do not need a separate assertion. Prefer those verified methods unless the handler has already validated the program account and deliberately needs the lower-overhead unverified variant. Static .invoke() and .invoke_signed() builders encode their target program and also need no separate program account assertion. Passing a constant such as &token::ID to an unverified invocation is accepted because the caller cannot substitute the value.

The analyzer resolves the validation method to Pina rather than trusting its spelling. It tracks authenticated account places separately from trusted expected-ID provenance, including aliases and the account value returned by a chained Pina assertion, then intersects both states across continuing control-flow paths. An unrelated account, attacker-controlled expected ID, or same-named local method cannot authorize the dynamic target. A check in one branch does not authorize a later call unless every continuing path establishes the same proof.

Call unverified CPI methods directly with method or UFCS syntax. Taking one as a function value is denied at the function item, including casts, assignments, containers, closures, and conditional expressions. This deliberate boundary keeps the exact target argument visible to the lint instead of approximating Rust’s full value and closure data flow. If a reviewed abstraction must store one of these functions, use a narrowly scoped lint allowance and document how it authenticates the supplied program.

To migrate existing code, remove assertions that exist only before .invoke(), .invoke_signed(), .invoke_with_program(), or .invoke_signed_with_program(). Keep exact-target validation before the explicitly unverified methods. Propagate assertion failure, and move conditional checks so every continuing path validates the target. Existing code that used a verified method only to satisfy this lint needs no API change.

require_writable_before_account_resize

Detects resize() without a preceding assert_writable() on the same account.

#![allow(unused)]
fn main() {
state.assert_writable()?;
state.resize(new_len)?;
}

The lint tracks lexical call order and receiver identity. It does not infer writability from comments, IDL metadata, or helper functions.

require_zeroed_before_close

Detects close() or close_with_recipient() without first zeroing the same account’s data. Prefer close_account_zeroed() or the CloseAccountZeroed builder, which zero the data and close in one step and are never flagged:

#![allow(unused)]
fn main() {
state.close_account_zeroed(&ID, recipient)?;
}

When the close must stay separate, clear the whole data buffer first:

#![allow(unused)]
fn main() {
state.try_borrow_mut()?.fill(0);
state.close_with_recipient(&ID, recipient)?;
}

This protects against stale bytes remaining observable during the transaction.

The zeroing proof is a fill(0) resolved to core’s slice method over the entire buffer returned by solana_account_view’s AccountView::try_borrow_mut()? (the type Pina and Pinocchio re-export), in method or fully qualified form: chained directly, through [..], or through a let binding of that buffer that is later only dropped. Closes are recognized in both forms too, so AccountView::close(state) and <AccountView>::close(&mut *state) are checked like state.close(). A partial fill (data[..8].fill(0)), a non-zero or non-literal fill, a same-named non-slice fill, and a same-named try_borrow_mut on another type are not proofs. The fill must also be the last write: a later try_borrow_mut() that could reach the same account, or any use of the zeroed buffer other than drop, before the close voids it. Writes through other paths, such as a typed as_account_mut() loader or a CPI, are not tracked.

“Same account” means both receivers resolve to the same local binding plus field path. A let alias is followed only when its initializer is a plain place (x, &x, &mut x, &mut *x, *x, x.field), so let alias = &mut *state; names state and zeroing or closing through it counts. A binding initialized any other way, such as let vault = next_account(&mut iter)?;, is its own account rather than an alias of the call’s argument. A receiver reached through indexing (accounts[0]) or a method or function call has no identity, so its close is always flagged; bind the account first. A binding that may hold a different value by the close has no identity either: one that is assigned (including through *alias = ..), lent as a slot (&mut binding, including mem::swap and addr_of_mut!), or captured by a closure.

The account’s place must also stay put. Lending the place, or any place it is reached through, mutably anywhere before the close voids the proof, even when the lend comes before the zeroing. A lend is any of:

  • a &mut borrow;
  • a ref mut binding, or a match, if let, or let whose default binding modes borrow it mutably;
  • passing a &mut place to a function;
  • calling a &mut self method outside solana_account_view, pinocchio, and pina, including a user function or method that only shares a close name, which lends its receiver to every other close;
  • a closure that captures the place mutably, or captures a &mut to it by value.

So rotate(ctx), ctx.rotate(), || rotate(ctx), and mem::swap(&mut ctx.escrow, ..) all void a proof for ctx.escrow, while lending the sibling &mut ctx.maker does not. A lend or try_borrow_mut() through an alias whose value may have changed since its let, or through an expression the lint cannot place (such as a getter’s result), is assumed to reach every account, so it voids every proof it could affect.

The zeroing must run on every path to the close: a fill inside an if or else branch, a match arm, the right side of &&/||, a loop body, a labeled block, or the else block of a let ... else proves only a close inside that same scope, because a condition, break, or failed pattern can skip it.

Known limits:

  • Methods from solana_account_view, pinocchio, and pina are trusted not to replace the account, so a write to its data through one of them after the fill (such as a typed as_account_mut() loader) is not seen.
  • A write by a CPI after the fill is not seen.
  • A write through a separately obtained handle to the same account (a copied or cloned AccountView, or one returned by a call) after the fill is not seen.
  • A lend that appears after the close inside a loop is not considered for the next iteration.
  • The check is lexical within one function body, and zeroing inside a closure never counts.

require_sysvar_assert_before_sysvar_use

Detects raw reads from accounts whose names identify known sysvars without a successful call to Pina’s assert_sysvar() using the matching canonical pina_sdk_ids::sysvar::<name>::ID.

#![allow(unused)]
fn main() {
clock.assert_sysvar(&sysvar::clock::ID)?;
let data = clock.try_borrow()?;
}

Prefer Pinocchio’s checked typed loaders when you need the sysvar value. They validate the account address while parsing, so a separate assert_sysvar() call would repeat the same check:

#![allow(unused)]
fn main() {
let clock = Clock::from_account_view(clock_account)?;
let rent = Rent::from_account_view(rent_account)?;
let instructions = Instructions::try_from(instructions_account)?;
}

Keep assert_sysvar() when code only validates identity or deliberately borrows the raw account data. A raw-access proof must call the resolved Pina method with the canonical ID. Known names such as clock_account must use the matching ID; generic names such as epoch_sysvar can establish proof with any recognized canonical sysvar ID. Enforce its Result with ?, unwrap(), or expect() on every continuing control-flow path; chaining from the returned account value is supported. Failure-side map_err() and inspect_err() adapters are also accepted before extraction. Discarded results, failure inspection, same-named methods, look-alike ID constants, and one-branch checks are not proofs. Success-side map(), and_then(), and inspect() adapters are not proofs because their callbacks can replace the asserted account before a later raw read. Assignments, mutable borrows, &mut self method calls, and closures that may replace a captured account invalidate the earlier proof.

The lint identifies Pinocchio constructors that do not validate identity by their resolved definition: Clock and Rent byte constructors, Instructions::new_unchecked, and SlotHashes::new / new_unchecked. It reports direct calls at their source and rejects storing these constructors as function values. This source boundary catches replacement through adapters, helper calls, mutable borrows, aliases, and destructuring without attempting to reconstruct arbitrary downstream value provenance.

To migrate existing code, replace manual byte parsing with the matching checked typed loader. Checked results and checked constructor function values can use ordinary Rust extraction, adapters, tuples, patterns, and control flow without special lint knowledge. Call an identity-unchecked constructor directly so its source remains visible to the lint. For deliberate raw parsing, call assert_sysvar() before borrowing the data, then place a narrow lint allowance directly on the reviewed constructor. No change is needed for identity-only checks or asserted raw account access. The separate raw-read heuristic still uses standard Solana sysvar account names, so unusually named raw accounts may require a direct, local assertion.

rent_account.assert_sysvar(&sysvar::rent::ID)?;
let data = rent_account.try_borrow()?;
// Reviewed exception: the preceding assertion fixes the raw data's identity.
#[allow(require_sysvar_assert_before_sysvar_use)]
let rent = Rent::from_bytes(&data)?;

require_type_assert_before_zero_copy_cast

Detects known bytemuck cast functions in pina::ProcessAccountInfos::process implementations and conventional process_instruction entrypoints. Unrelated functions or inherent methods named process remain outside the lint boundary. Use a Pina conversion that validates and borrows the account as one operation.

#![allow(unused)]
fn main() {
let vault = account.as_account::<Vault>(&ID)?;
}

For PDAs, prefer the generated load_pda* methods because they also validate the address and stored bump. assert_type::<T>() remains useful when a handler only needs validation, but it is a moment-in-time check and does not make a later raw cast safe. Pina instruction and account try_from_bytes() associated functions are safe framework conversions and are not treated as raw casts.

require_reason_for_duplicate_remaining_accounts

Detects #[pina(remaining, distinct = false)] on mutable remaining accounts unless the field has a doc-comment explanation of at least five words.

#![allow(unused)]
fn main() {
/// Duplicate entries represent votes and are deduplicated before mutation.
#[pina(remaining, distinct = false)]
pub votes: &'a mut [AccountView],
}

#[pina(remaining)] is distinct by default. The word threshold only rejects missing or placeholder explanations; reviewers must still verify the stated invariant.

deny_unchecked_remaining_mut

Detects direct calls to Pina’s AccountsCursor::remaining_mut(). The method validates writability but preserves duplicate addresses, so one logical account can appear more than once in the returned mutable slice.

#![allow(unused)]
fn main() {
let remaining = cursor.remaining_mut_distinct()?;
}

The lint resolves the method definition before reporting, so same-named methods from other crates are ignored. It rejects method syntax, UFCS calls, stored function items, and calls hidden by local or external macros. Pina’s #[derive(Accounts)] expansion remains exempt: the macro uses remaining_mut() only for the explicit, documented #[pina(remaining, distinct = false)] escape hatch, which is checked separately by require_reason_for_duplicate_remaining_accounts.

require_canonical_bump_before_pda_write

Detects assert_seeds_with_bump() in instruction paths unless the same account has already passed assert_canonical_bump() or assert_seeds().

#![allow(unused)]
fn main() {
let canonical = state.assert_canonical_bump(&seeds, &ID)?;
if canonical != supplied_bump {
	return Err(ProgramError::InvalidSeeds);
}
state.assert_seeds_with_bump(&seeds_with_bump, &ID)?;
}

Multiple valid bump values can otherwise create multiple addresses for one logical namespace. See Solana’s PDA documentation. The lint tracks lexical receiver identity; it cannot inspect opaque validation helpers.

This lint applies to validation-only assertion chains. CreateProgramAccountWithBump and CreateCompactProgramAccountWithBump enforce canonicality inside the builder, so creation handlers should not call either assertion first. Prefer the canonical builders when the instruction does not need to carry a bump.

deny_account_borrows_across_cpi

Detects CPI while a local returned by try_borrow_mut() or as_account_mut() is still alive.

#![allow(unused)]
fn main() {
let amount = {
	let state = account.as_account_mut::<State>(&ID)?;
	state.amount.get()
};
transfer.invoke()?;
}

An explicit drop(guard) or the end of a nested block releases the guard. The analysis follows block scope, locally bound closure calls, match guards, nested binding patterns, guard-returning aliases, and calls to the real std::mem::drop. Closure bodies are evaluated with the borrow state at each visible invocation, so defining a callback before a borrow or dropping a borrow before invoking it is modeled in execution order. It resolves method and guard types before classifying a borrow or CPI, so unrelated same-named operations do not create or discharge a proof. Account borrows hidden inside custom wrapper constructors, closures invoked through opaque higher-order helpers, and CPIs hidden behind opaque helpers are outside its current model.

deny_unused_account_borrow_guards

Detects account borrow guards bound to locals that are never read.

#![allow(unused)]
fn main() {
// Flagged: the guard is bound but never read, so the account data borrow
// stays open until the end of the enclosing scope for nothing.
let _guard = mint.as_token_mint_for_program(&token_program)?;

// Preferred: discard the validation value immediately.
mint.as_token_mint_for_program(&token_program)?;
}

Assertion-style guards exist only for their ? validation. Binding them without reading keeps the borrow open to the end of the scope, which obscures the borrow boundary and can turn a later borrow of the same account data into a runtime panic. Discard immediately by calling the validation as a ? statement — optionally wrapped as drop(account.try_borrow()?) — or write let _ = account.try_borrow()?; at the creation site; when the value matters, read it. let _ = guard; does not move an existing local in Rust, so it does not release the borrow and remains a lint warning. Passing a bound guard to a later drop(local) is also flagged because the borrow stayed open in between. The lint recognizes the concrete solana_account_view::Ref and RefMut binding types re-exported by Pinocchio and Pina, including aliases, values returned through function pointers, and bindings nested in tuple or let ... else patterns. It counts any use of the binding — method calls, field access, & borrows, and closure captures — as a read. Wrapper types that contain a guard are outside its current model. A drop(local) call is treated as a discard rather than a read only when it resolves to std::mem::drop; a shadowing drop function is an ordinary use.

The warning carries a suggested rewrite: pina lint --fix rewrites the binding into the immediate ? statement. When the only other occurrence of the binding is a later drop(local);, the suggested edit also removes that statement — but it is marked MaybeIncorrect rather than machine-applicable, because releasing the borrow earlier than the user wrote it is observable to the code in between and needs human review. Suggestions are withheld entirely when the guard binding originates inside a macro expansion or when a drop appears outside a statement (for example inside a closure tail), because the rewrite could not be applied safely there.

require_consistent_token_program

Detects token parsing, ATA derivation, and dynamic token CPI calls that use different program identities within one instruction function.

#![allow(unused)]
fn main() {
token_program.assert_addresses(&SPL_PROGRAM_IDS)?;
let program_id = *token_program.address();
let mint = mint.as_token_mint_for_program(&program_id)?;
transfer.invoke_with_program(&program_id)?;
}

The lint compares resolved identifier paths, including module-qualified constants, so token::ID and token_2022::ID cannot collapse to the same terminal name. Immutable local aliases are traced back to their original identity, allowing clear names for parsing and CPI without reporting a mismatch. It still rejects reassignment of a program binding between token operations, because the same lexical name would otherwise hide a changed value. Copy and reuse a single immutable, validated address instead of independently deriving, mutating, or hard-coding program IDs.

require_explicit_token_2022_extension_policy

Detects Token-2022-capable mint loads without an explicit call to assert_no_extensions() or assert_extensions_allowed() in the instruction function.

#![allow(unused)]
fn main() {
let mint = mint_account
	.as_token_mint_for_program(&program_id)?
	.assert_extensions_allowed(&[
		token_2022::state::ExtensionType::ImmutableOwner,
	])?;
}

Extensions can alter transfer, fee, hook, freeze, and authority semantics. Pina therefore requires an allow-list instead of treating the legacy base layout as a complete policy. The analysis pairs a policy with the concrete mint-view binding or with the same direct method chain; a policy asserted on a different mint does not satisfy the rule. Keep each policy adjacent to its mint load so the pairing also remains obvious to reviewers.

The analysis preserves a checked view through resolved Result extractors such as ?, unwrap(), and expect(). It also handles adapters that cannot replace the successful value, such as map_err() and inspect(). The lint does not infer that map() or and_then() is an identity transform, even when a closure appears to return its input. Bind and assert the checked view before a custom success-value transformation. After you review another pattern, use a narrow allowance.

An as_token_mint_for_program(&token::ID) call with the canonical legacy SPL Token ID is exempt because Token-2022 extensions cannot be present. Dynamic program identities and the explicit Token-2022 loaders still require a policy.

Both policies are inherent, chainable methods on TokenMintRef and TokenAccountRef; they return the validated view rather than wrapping it in a separate free-function API.

as_token_mint_for_program() and as_token_account_for_program() only accept the canonical SPL Token and Token-2022 program IDs, require the account owner to match the selected ID, and parse the corresponding concrete layout. The caller therefore cannot make a legacy account appear to be Token-2022 (or vice versa) by supplying an arbitrary address. Extension assertions are a no-op on the validated legacy variant and inspect the actual TLV extension data on the validated Token-2022 variant.

require_post_cpi_balance_reload

Detects a token balance snapshot that is trusted after a value-moving token CPI changed the balance it describes. Two tiers apply to every builder of a Transfer, TransferChecked, MintTo, or MintToChecked instruction:

  • Snapshot tier (every destination). After a value-moving CPI into an account, two kinds of value are tracked:

    • A snapshot-derived value is an integer read of the account’s balance taken before the CPI, or any local, conversion, or arithmetic result computed from one.
    • A reload is a read of the same account after the CPI that runs on every path to the use. It may not sit only inside an if arm, a closure, or a loop the use is outside of. A reload-derived value is a reload, or any local, conversion, or arithmetic result computed from one.
    • Conversions carry the value unchanged: as casts, borrows, and the integer-to-integer From::from, Into::into, TryFrom::try_from, and TryInto::try_into, resolved through their traits, with any ?, unwrap, expect, or map_err after the fallible forms. So u128::from(after).checked_sub(u128::from(before)), i128::from(after) - i128::from(before), and let before: u128 = ata.amount().into() work like the plain u64 forms.

    After the CPI, a snapshot-derived value may appear only as:

    1. one side of a comparison (==, !=, <, <=, >, >=, .eq(), .cmp(), …) whose other side is reload-derived or a constant, looking through &. This verifies the real balance: if after != before + 10, let expected = before.checked_add(10)?; if after != expected, before.cmp(&after), and if prior == 0 all pass;
    2. the subtrahend of a subtraction-like operation whose minuend is a reload: -, checked_sub, saturating_sub, wrapping_sub, or overflowing_sub, in method or function-call syntax (u64::checked_sub(after, before)), or either operand of the symmetric abs_diff. The result is a delta, which is reload-derived and no longer stale: after - before, after.checked_sub(before), after as u128 - before as u128. The reverse sign (before - after) is not the amount received and stays snapshot-derived; or
    3. an operand of an addition-like operation (+, checked_add, saturating_add, wrapping_add, overflowing_add) whose other operand is a delta, directly or through a local: before + (after - before), or let delta = after.checked_sub(before)?; before.checked_add(delta).

    Every other appearance is a stale use:

    • other arithmetic whose result is then returned, stored, or passed on;
    • a call argument, a return value, or a tuple, struct field, or array element;
    • an addition with a bare reload (before.checked_add(after)); and
    • arithmetic that cancels the reload out (before + after * 0, before.wrapping_add(after - after)).

    A value bound to a local that is never read goes nowhere and is not reported. This covers user_stake_ata, treasury, fee_receiver, and any other name. Unrelated CPIs between the transfer and the reload are allowed.

    Logging or emitting the pre-transfer balance is a stale use by design: an event that reports before as a balance after the CPI publishes a value the chain no longer holds. Log the reload and the delta instead (log(after); log(received)). If the old balance must be recorded, compute and record it before the CPI, or place a narrowly scoped #[allow(require_post_cpi_balance_reload, reason = "...")] on the handler. Likewise, after verifying if after != expected { return Err(..) }, return after rather than expected.

  • Custody tier (custody-named transfer destinations). A transfer into an account whose name contains vault, custody, reserve, or pool must be bracketed by destination reads with no other CPI in between, even when no snapshot exists yet, because a custody deposit is only safe to credit from the observed delta. Where the typed identity below cannot name the destination or one of its reads, the tier falls back to the name-based check (reads whose written receiver matches the written destination), so code that check accepts is not newly rejected for that reason.

#![allow(unused)]
fn main() {
let before = user_stake_ata.as_token_account_for_program(&program_id)?.amount();
transfer.invoke_with_program(&program_id)?;
let after = user_stake_ata.as_token_account_for_program(&program_id)?.amount();
let received = after.checked_sub(before).ok_or(ProgramError::ArithmeticOverflow)?;
}

Token-2022 transfer fees can make received differ from the requested amount; Solana’s on-chain Token-2022 guide describes this accounting requirement.

What counts as a builder. A new or with_multisig_signers constructor qualifies when all of the following hold:

  • Its resolved return type, after unwrapping Result and Option, has a name ending in Transfer, TransferChecked, MintTo, or MintToChecked. So SplTransfer and the real transfer_checked::TransferChecked both count.
  • Its signature leads with parameters that are references to a struct or generic type, followed by an integer amount.
  • It leads with enough account parameters. A builder defined in a token crate (pinocchio_token, pinocchio_token_2022, spl_token, spl_token_2022, spl_token_interface, or pina) needs three: it is a token instruction by where it comes from. A builder defined anywhere else needs four for a transfer (from, mint, to, authority), because naming the mint is what a lamport transfer never does, and three for a mint.
  • It is not defined in pinocchio_system or solana_system_interface.

So AuthorityTransfer::new(config, new_authority, signer) (no amount) and a local LamportTransfer::new(payer, vault, system_program, lamports) (no mint) do not count. The destination is the third account when four lead and the second otherwise.

Which account an expression names. Every local is keyed by its binding, never by its name, so shadowed locals and let-else, if let, and match bindings that share a name stay distinct. Fields extend the key with their full path, so ctx.user_ata, ctx.fee_ata, and ctx.vault stay distinct whatever their field types are. Only these steps are looked through:

  • let aliases, &, *, and ?;
  • Pina’s token-view methods (as_token_account(), as_token_account_for_program(), as_token_2022_account(), as_associated_token_account(), and as_account());
  • the token crates’ state loaders (TokenAccount::from_account_view() and the _unchecked/from_account_info variants), keyed by their first argument;
  • the .base field of a loaded Token-2022 view; and
  • Option/Result adaptors that pass the success value through (ok_or, ok_or_else, unwrap, expect, map_err), and Pina’s assert_* checks, which return the account they checked.

Cursor methods get a key unique to their call site, so two it.next() calls never name the same account. These are Iterator::{next, nth}, DoubleEndedIterator::{next_back, nth_back}, and Pina’s AccountsCursor::next*. Every other method call with constant arguments is keyed by its receiver, the method’s resolved definition, and its arguments, whether or not it takes &mut self. So an accessor such as ctx.vault_mut() names the same account on every call, and accounts.get(2) differs from accounts.get(3). A let binding initialized from a non-cursor &mut self method call (looking through ? and Option/Result adaptors) is keyed by that call and its binding name. So let fee = cursor.take()?; let vault = cursor.take()?; never collide, while rebinding the same accessor under the same name (let vault = ctx.vault_mut(); before and after the transfer) names one account, as the name-based check treats it. Because such a call may be an accessor or a hand-written cursor, the custody tier defers to the name-based verdict whenever the destination or a balance read passes through such a binding. Anything else, such as a dynamic index or a call with a non-constant argument, names no account, and a read that names no account never matches a destination.

What counts as a read. .amount() and Type::amount(account) count, outside closures. A read inside a closure only happens if the closure runs, so it counts for neither tier. Snapshots are followed through tuple destructuring, verbatim copies (let snapshot = before;), and assignments (before = ata.amount();).

Unreachable uses. A use the CPI cannot reach is not stale: the CPI sits in a block that always returns, or the use is in a sibling if/match arm.

Legacy program exemption. A static invoke() or invoke_signed() is exempt when both of these hold:

  • its receiver’s full type is the constructed builder; and
  • that builder’s program type parameter is pinocchio_token::TokenProgram.

That call targets the legacy SPL Token program, which has no transfer-fee extension, so the requested amount is exactly what arrives. The following stay covered:

  • a wrapper’s invoke();
  • any expression that yields a Token-2022 builder instead, such as pick(legacy, token_2022).invoke();
  • Pina’s token_2022 aliases;
  • local look-alikes; and
  • every runtime-program invocation (invoke_with_program(), invoke_with_unverified_program(), and their signed variants).

Limits.

  • A snapshot behind a helper function (let before = read_balance(ata)) or stored in a struct field (Snap { before: ata.amount() }) is not tracked.
  • A snapshot-derived value that flows into a non-integer local (such as let x: Option<u64> = before.checked_add(10);) is not followed further and is reported at that binding.
  • A snapshot that starts out as a non-integer value, such as Some(ata.amount()) or ata.amount().checked_add(0) bound to an Option<u64>, is never tracked, so its later uses are not checked.
  • A delta that cancels itself out (let d = after - before; before + d - d + 10) is accepted: the addition with the delta makes the result reload-derived, and later arithmetic on a reload-derived value is not re-examined.
  • A destination that names no account gets no snapshot analysis. The custody tier still requires reads for it, through the name-based fallback.
  • A local builder that transfers without naming the mint (from, to, authority, amount) is only covered when it comes from a token crate.
  • A &mut self method other than the listed cursors, called inline more than once (cursor.take()?.amount() twice), is assumed to return the same account on every call with the same constant arguments. Bind each result with let to give it its own identity. Binding two results of the same call under the same name treats them as one account. So rebinding a hand-written cursor as let acct = cursor.take()?; before and after the transfer counts the second account’s read as the first account’s reload, and a stale snapshot of the first account is not reported. Give each cursor result its own name.
  • A read through a let-bound &mut self result and a transfer into a separate inline call of the same method (let vault = ctx.vault_mut(); let before = vault.amount(); transfer(ctx.vault_mut())) are different identities, so the snapshot tier does not check that snapshot. Use the binding for both the reads and the transfer.
  • A local’s value is taken from its lexically latest definition before the use. Writes through &mut references to it are not tracked.
  • Builders passed through opaque wrappers are not associated with their invocation.
  • Code is ordered lexically, so loops are analysed in source order.

Audit such code manually or keep the transfer and the reads direct in the instruction handler.

require_checked_asset_arithmetic

Detects raw +, -, *, and /, plus saturating or wrapping arithmetic, when an operand has an economic identifier component such as amount, balance, lamport, price, reward, stake, or supply.

#![allow(unused)]
fn main() {
let next_balance = balance
	.checked_sub(amount)
	.ok_or(ProgramError::ArithmeticOverflow)?;
}

Saturating arithmetic is rejected because silently clamping economic state can violate conservation just as surely as wrapping. The check applies to primitive integers; custom domain types own their arithmetic contract and are not given an inapplicable checked_* suggestion. Components are split at Rust identifier separators, so vault_balance is covered while an unrelated name such as rebalance_attempts is not. The naming heuristic favors clear domain names and may not recognize opaque abbreviations.

require_bounded_remaining_accounts

Detects loops whose source mentions remaining unless the iterator visibly uses .take(MAX) or a dominating constant-bound length guard rejects oversized input first.

#![allow(unused)]
fn main() {
const MAX_REMAINING_ACCOUNTS: usize = 16;
if remaining.len() > MAX_REMAINING_ACCOUNTS {
	return Err(ProgramError::InvalidArgument);
}
for account in remaining {
	process(account)?;
}
}

Remaining accounts are caller-controlled; an explicit bound keeps worst-case compute auditable. Rejecting an oversized list is preferred when every supplied account must be processed, while .take(MAX) is suitable only when ignoring surplus accounts is intentional. Standard adapters that cannot increase cardinality, such as filter, map, and enumerate, preserve a preceding take; expanding adapters such as flat_map must be bounded afterward. The guard must compare remaining.len() against an integer literal or resolved constant, return early on the oversized path, and dominate the loop. The analysis follows local aliases and computes loop-carried state to a fixed point. Reassignment, mutable borrows, &mut self calls, and closures that may replace a checked binding invalidate its bound, including for later iterations of an enclosing loop. A runtime limit, branch-local check, late check, or opaque helper does not satisfy the rule because it does not establish a source-visible protocol maximum on every path.

require_guarded_full_balance_drain

Detects an instruction handler that sends an account’s entire lamports() balance with send or send_owned unless a pause, circuit-breaker, or withdrawal-cap guard is enforced earlier in the same or an enclosing scope (not inside a branch, loop body, closure, or the right operand of &&/||), or the same account is closed first.

#![allow(unused)]
fn main() {
fn enforce_withdrawal_policy(config: &VaultConfig) -> Result<(), ProgramError> {
	assert_not_paused(config)?;
	config.assert_within_window_cap()
}

enforce_withdrawal_policy(&config)?;
vault.send_owned(&ID, vault.lamports(), recipient)?;
}

A call counts as the guard only when all of the following hold:

  • Its failure stops the handler. A Result/Option guard is propagated with ?, extracted with unwrap()/expect(), returned, or tested by a match, if let, or let ... else. Every arm that can receive the failure must return Err/None (or evaluate to one when the whole expression is itself returned or propagated), return the scrutinee’s own binding, or panic. Arms are read in order, so a _ after an unguarded Err(_) arm only receives success. Adapters that keep the failure are followed: map_err, inspect_err, map, and_then, and, ok, ok_or, or(Err(..)), or_else whose fallback can only fail, clone(), and into()/From::from into a Result or the same type. or(Ok(..)), or(Some(..)), a recovering or_else, unwrap_or, a conversion into Option<Result<..>>, and a discarded result are not.
  • Polarity is checked. A bool guard, is_err()/is_ok(), or eq/ne/==/!= against Ok(..) of a fallible one must gate an if, assert!, assert_eq!, assert_ne!, or pina::assert(ok, error, message)? so that execution continues only on the passing value. So if guard().is_ok() { return Ok(()) }, assert_eq!(guard().is_err(), true), and match guard() { Ok(()) => return Err(..), _ => {} } do not gate the drain that follows. A guard that returns a bool itself has no known polarity, so a failing branch on either side of its if counts.
  • A failing branch fails. It returns Err/None, returns a local helper that can only fail (return reject()), or panics. A guard-named local method returning (), such as fn assert_not_paused(&self) { assert!(!self.paused) }, counts where it is called when its body can panic. A branch that returns Ok, breaks, or continues is not a failure. An early return Ok(..) on the paused path, such as if state.is_paused() { return Ok(()) }, deliberately does not count. For a bool guard the lint cannot tell which value is the failing one, and reporting success for a blocked sweep hides the pause from callers.
  • It reads the handler’s inputs. Its receiver or an argument must be derived from a function parameter (including self), directly or through locals bound from one. A zero-argument call, or one fed only literals and constants (even through a local such as let zero = 0;), cannot inspect the state it claims to guard.
  • It names the check, or delegates to one. It is a function or method whose name contains pause, cap, circuit, halt, guard, limit, or throttle, because behavior alone cannot tell a cap check from assert_signer()?, which every handler propagates. Closures, fn pointers, and generic callables are named by their binding and never count by name. A differently named local function that returns Result/Option counts when its own body enforces such a guard in its outermost scope before any early success return or break, followed up to three wrappers deep, so enforce_withdrawal_policy(&config)? above is accepted. A wrapper returning bool is never followed. A generic wrapper is instantiated with its caller’s arguments, so fn policy<T: Guarded>(t: &T) -> Result<..> { t.check_cap() } is judged by the impl its caller passes, not by the name check_cap. Inside a wrapper, a trait call that cannot be resolved (dyn, an unconstrained generic) counts as neither a guard nor a failure, and so does a return of a value the lint cannot see into (return Ok(()).into(), return identity(Ok(())), return finish.finish()). A guard bound to a local counts where the local is enforced, unless it is reassigned or mutably borrowed first. A labeled block that can break with a success does not carry its tail guard out.
  • It is not a constant success. A local callee whose every returned value is a literal Ok(..)/Some(..) (or a bool literal), directly or through a never-reassigned let binding, and that has no reachable ?, Err/None, or panic, is not a guard, whatever its name. Branches behind a literal if true/if false count as unreachable. Trait method calls are judged by the implementation that runs, never by a default body that the implementation overrides. In a generic handler (not a wrapper) where the implementation cannot be resolved, only the method name is used.

Known limits:

  • Callees from other crates are judged by their name and call-site behavior only, and a unit guard from another crate never counts. In the handler itself, but not in a wrapper, a return helper() on the failing side whose helper cannot be analyzed counts as a failing branch, as the name rule did before, because any return there skips the drain.

  • async fn handlers and guards are not analyzed through .await, which does not arise in SBF programs.

  • A local guard-named callee counts if anything in its body can fail, even for reasons unrelated to its claimed check. The constant-success test does not evaluate conditions beyond literal true/false.

  • Wrappers deeper than three levels are not followed.

  • A pause check with no guard-named call, such as if state.paused { return Err(..) } or a differently named helper taking only the flag, is not recognized.

  • Some correct guards are still reported, because the lint errs toward warning when it cannot prove the failure stops the handler:

    • unwrap_or_else(|_| panic!(..)) on a guard’s result;
    • a guard result that is reassigned before it is propagated (res = res.map_err(..); res?);
    • a guard bound through tuple destructuring;
    • a unit guard that fails through .expect() rather than assert!/panic!;
    • a guard whose receiver is a static.

    Propagate the guard’s result directly with ? to satisfy the lint.

Performance reference

deny_heap_allocations_in_onchain_instruction_handlers

Warns on collect, to_vec, to_string, clone, format!, Vec creation, and String creation in functions whose names identify instruction handlers.

#![allow(unused)]
fn main() {
let mut bytes = [0u8; MAX_MESSAGE_BYTES];
bytes[..input.len()].copy_from_slice(input);
}

The lint is a performance warning rather than a correctness denial because some off-chain or bounded on-chain designs may intentionally allocate. It uses method and function-name heuristics and does not estimate actual heap size.

IDL and example-structure reference

require_idl_root_to_define_one_program_id

Warns when an IDL-oriented example or security crate does not expose exactly one crate-root declare_id! expansion.

#![allow(unused)]
fn main() {
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
}

The check is repository-scoped by source file: declare_id! expansions are inspected in crates whose sources live under an examples or security directory. The crate-root program id is the contract, and every additional declaration is reported at its own call site, so module-scoped #[allow(...)] suppresses only the extra declarations (as examples/declare_program demonstrates). Library crates that intentionally define no program are ignored.

require_canonical_instruction_dispatch_for_idl

Warns when process_instruction or an entrypoint does not directly contain a match over parsed instruction data.

#![allow(unused)]
fn main() {
match instruction {
	Instruction::Initialize => InitializeAccounts::try_from((program_id, accounts))?.process(data),
	Instruction::Update => UpdateAccounts::try_from((program_id, accounts))?.process(data),
}
}

The check keeps dispatch visible to pina idl and reviewers. It verifies the presence of direct match-shaped routing, not semantic exhaustiveness.

require_explicit_discriminators_and_seed_namespaces

Warns when seed assertions in example instruction paths do not visibly use a byte-string namespace, a named SEED/SEED_*/*_SEED constant, or a generated Pina seed helper.

#![allow(unused)]
fn main() {
const SEED_VAULT: &[u8] = b"vault";
vault.assert_seeds(&[SEED_VAULT, authority.address().as_ref()], &ID)?;
}

Associated seed helpers generated from #[pda(...)] are accepted because the macro declaration exposes the namespace at the account type. Receiver-less local functions named like assertion methods are not treated as framework proof. The rule is a reviewability warning and does not replace canonical bump validation.

Suppression policy

Prefer making validation and bounds explicit instead of suppressing a finding. When a false positive cannot be expressed more clearly, scope #[allow(...)] to the smallest item and add a doc comment explaining the invariant. Deny-level security lints should not be disabled at crate or workspace scope.

Testing

UI fixtures live under tests/ui/<lint>/. Each fixture is compiled with the bundled pina_lint_driver — the lints are statically linked into the driver — and the emitted diagnostics are compared with the committed .stderr file next to the fixture. PINA_LINT_ONLY restricts the driver to the lint under test, so each fixture observes the same single-lint behavior the previous one-library-per-lint Dylint setup had.

Fixtures support two directives:

  • // aux-build: <name>.rs — compile auxiliary/<name>.rs first and pass it to the fixture through --extern. The auxiliary source chooses its own crate type through #![crate_type] (for example proc-macro fixtures); sources without an inner attribute fall back to a plain library.
  • // normalize-stderr-test: "<regex>" -> "<replacement>" — rewrite the actual stderr before comparing it with the expectation. Paths under the fixture directory are replaced with $DIR first, mirroring the convention of the Rust repository’s UI tests.

To update a .stderr expectation, run the test, copy the saved actual stderr over the .stderr file, and re-run. On a mismatch the harness saves the actual stderr to a pina-lints-ui directory under the system temp directory and prints the saved path in its failure report.

Lint reference

Every lint Pina ships, with the contract it enforces, why violating it is a vulnerability, and how to bless an intentional exception.

pina lint --explain <LINT> prints the same entry for one lint without leaving the terminal.

How to read this page

A lint is a claim about your program, and a finding is the compiler telling you it could not prove that claim. Two things follow from that:

  • Fix the finding, do not silence it. Every entry names the API or restructure that satisfies the contract. That is the intended resolution.
  • Blessing is a documented decision, not a suppression. Where an entry describes an #[allow], it is scoped to the smallest item and the entry says what to write in the comment. A crate-wide allow in pina.toml removes the signal for everyone, including the next person who adds a genuine violation to the same module.

Configuring levels

Default levels are set per lint. Override them in the [lints] table of pina.toml:

[lints]
# Raise a heuristically-noisy warn to a hard requirement.
deny_heap_allocations_in_onchain_instruction_handlers = "deny"
# Accept a documented, deliberate exception at crate scope.
require_explicit_discriminators_and_seed_namespaces = "allow"

Prefer an item-scoped #[allow] over a crate-scoped allow, with a comment naming the invariant that makes the exemption safe.

Default levels

A deny lint is a security property: the build fails, and there is no supported way to ship without addressing it. A warn lint is heuristically detected or advisory, so a false positive is expected occasionally and the blessing guidance is what matters.

deny_account_borrows_across_cpi

Default level: deny

Contract. Drop every mutable account-data borrow before invoking another program.

Why this matters. The invoked program may need the same account. Holding a RefMut across the CPI makes the invocation fail at runtime and hides the re-entrancy boundary the borrow was documenting.

Blessing an exception. Call drop(guard) before the CPI, or narrow the borrow so it ends before the invocation. Prefer restructuring over an #[allow]: the attribute silences the check without ending the borrow, so the runtime failure returns.

deny_colliding_account_discriminators

Default level: deny

Contract. Every account type’s HasDiscriminator::VALUE carries a numeric value no other account type in the program also claims at the same discriminator width.

Why this matters. Pina discriminators are author-chosen integers, and rustc only rejects duplicates within one enum. Two account types behind different enums that agree on the value and the serialized width pass every typed loader check — owner, discriminator, exact size — so either account deserializes as the other: the sealevel-attacks type-cosplay class.

Blessing an exception. There is no sound exception; two account types at one value is a latent vulnerability. Give the colliding variant a fresh value (wire values are part of the ABI, so ship it as a migration), or consolidate all accounts behind one discriminator enum, where rustc makes the collision impossible.

deny_heap_allocations_in_onchain_instruction_handlers

Default level: warn

Contract. Avoid heap allocation in instruction handlers.

Why this matters. On-chain code is charged for every allocated byte and for the code that manages it. Borrowed slices, stack buffers, and fixed-size POD types keep both compute units and deployed size down.

Blessing an exception. Warns by default and is heuristic: it matches allocation-prone method names in handler-like functions, so a non-allocating Clone implementation or a foreign API can trip it. Scope #[allow] to the individual item and say which call is known not to allocate.

deny_unchecked_remaining_mut

Default level: deny

Contract. Reach remaining accounts through remaining_mut_distinct() or the derive attribute rather than AccountsCursor::remaining_mut().

Why this matters. remaining_mut() validates writability but preserves duplicate addresses. Mutating through two aliases to one account applies a single logical update twice, which is the duplicate-mutable-account vulnerability class.

Blessing an exception. An instruction that genuinely must accept a repeated address — a self-transfer, for example — should use #[pina(remaining, distinct = false)], which documents the exception at the field that takes it. That is preferred over a raw remaining_mut() call, which the lint cannot distinguish from an oversight.

deny_unused_account_borrow_guards

Default level: warn

Contract. Read or discard an account borrow guard immediately instead of binding it to a local.

Why this matters. The guard keeps the account-data borrow alive until the end of the enclosing scope. Binding and never reading it holds the borrow open for nothing and turns a later borrow of the same data into a runtime panic.

Blessing an exception. Bind the guard as _guard if the borrow must outlive the statement, or call drop(guard) where it should end. Both state the intent; an #[allow] does not, and the borrow outlives the attribute either way.

require_bounded_remaining_accounts

Default level: deny

Contract. Bound every loop over remaining accounts with a constant .take(MAX) or a dominating constant-bound length check.

Why this matters. The caller controls how many accounts arrive. Linear per-account work over an unbounded count turns into compute exhaustion, which is a denial of service against the instruction.

Blessing an exception. Define the maximum as a const MAX: usize and apply it with .take(MAX), or reject oversized input first with a constant-bound length check. A caller-derived bound does not satisfy the contract: the lint requires a constant so the worst-case cost is auditable from the source.

require_canonical_bump_before_pda_write

Default level: deny

Contract. Prove a PDA bump is canonical with assert_canonical_bump() before accepting a PDA through assert_seeds_with_bump(). assert_stored_bump() is the generated counterpart of assert_seeds(): it reuses a bump the handler parsed from the same account, and this lint requires that provenance.

Why this matters. A program-derived address has one canonical bump. Accepting any valid bump lets one seed namespace resolve to several addresses, breaking the uniqueness the seeds were chosen to provide. assert_stored_bump() names the one legitimate source for an explicit bump — the account’s own stored field, read in this instruction — so the provenance is checked rather than assumed.

Blessing an exception. CreateProgramAccount and CreateProgramAccountWithBump validate canonicality internally and need no assertion. Where several addresses per namespace are genuinely intended, use CreateProgramAccountWithUncheckedBump, which names the decision. assert_stored_bump() passes only when its bump argument resolves to a parse of the same account; a bump from instruction data or a different account fails. Reach for #[allow] only on a validation-only path that accepts non-canonical bumps by design, and name that invariant in the comment.

require_canonical_instruction_dispatch_for_idl

Default level: warn

Contract. Match directly on the parsed instruction enum in the entrypoint.

Why this matters. IDL extraction starts from the entrypoint. An explicit match over the instruction enum is what lets the extractor resolve every accounts struct, so hidden dispatch means a program whose IDL is incomplete or wrong.

Blessing an exception. Restructure the dispatch. If the indirection is required, scope an #[allow] to the entrypoint function and note which construct the extractor cannot follow.

require_checked_asset_arithmetic

Default level: deny

Contract. Use checked arithmetic for values that carry an economic quantity: balances, amounts, prices, rewards, stakes, supply, or lamports.

Why this matters. Silent overflow, underflow, or saturation corrupts an economic invariant without failing. The corruption is often permanent and can be worth real tokens, so the arithmetic must fail loudly.

Blessing an exception. Use the checked operation and map the error into the program’s error enum. An #[allow] is appropriate only where the invariant is proven by the surrounding code, and the comment should state the bound that makes it safe.

require_consistent_token_program

Default level: deny

Contract. Use one token-program identity for parsing, ATA derivation, and dynamic token CPI within one instruction.

Why this matters. Mixing identities can validate an account under one token program and then invoke another. The ownership and address assumptions that justified the validation no longer hold for the program that acts.

Blessing an exception. Decide the token program once and thread that single value through: read it from the TokenAccountRef or TokenMintRef you already resolved, whose from_account_view validated that the account belongs to ID or crate::token_2022::ID. Where a program supports both in one instruction, branch on the identity first and keep each branch internally consistent.

require_explicit_discriminators_and_seed_namespaces

Default level: warn

Contract. Give seed-based code an explicit byte-string namespace and make discriminator markers visible.

Why this matters. Explicit namespaces keep seed derivation auditable and let the IDL extractor follow the program’s account layout. Without them, two account roles can share a namespace and collide.

Blessing an exception. Declare the namespace as a named const byte string, for example const SEED_CONFIG: &[u8] = b"config";. The lint checks that a namespace is visible, not that all namespaces differ; compare them across account roles yourself.

require_explicit_token_2022_extension_policy

Default level: deny

Contract. State which Token-2022 extensions an instruction accepts before it reads a Token-2022-capable mint’s fields.

Why this matters. Extensions change transfer and authority semantics — transfer fees, permanent delegates, transfer hooks. Reading only the legacy base fields silently treats those semantics as irrelevant, and accounting computed from them is wrong.

Blessing an exception. Assert the policy explicitly on the mint: assert_extensions_allowed(&[...]) for the extensions the program handles, or assert_no_extensions() when it handles none. Do that rather than allowing the lint, because the policy is the thing the lint is asking for.

require_guarded_full_balance_drain

Default level: warn

Contract. Gate an instruction that can sweep an account’s entire balance behind a pause, circuit breaker, or withdrawal cap.

Why this matters. An ungated full-balance drain is the shape real key-compromise exploits use. Once the sweep authority leaks, nothing on-chain slows the drain; a pause switch plus a per-window cap bounds the blast radius.

Blessing an exception. Add the guard, or express the operation as a close when that is the intent, since a close states where the remaining lamports go. The guard must behave like one: its name states the pause or cap check (or it is a local wrapper returning Result that enforces such a guard before any early success return), its receiver or an argument is derived from the handler’s parameters, and it stops the handler on the failing value with ?, unwrap, or a branch that returns Err or panics; the polarity of is_err, is_ok, and assert_eq! is checked. A discarded result, a branch that returns Ok, a zero-argument or literal-only call, a closure, a local callee that can only return a literal success, and a generic wrapper whose concrete impl does not enforce the guard do not count. Where a drain is intended and bounded elsewhere, scope #[allow] to the handler and name the compensating control.

require_idl_root_to_define_one_program_id

Default level: warn

Contract. Define exactly one program ID at the crate root.

Why this matters. IDL extraction starts from the crate root and expects a single declaration. Several IDs make the resolution ambiguous, and none means the extractor has no anchor.

Blessing an exception. Keep one declare_id! at the root and move test or auxiliary IDs behind #[cfg(test)]. A crate that exports a program ID for another crate to consume should be a library, not the IDL root; scope #[allow] to that item if the layout must stay.

require_post_cpi_balance_reload

Default level: deny

Contract. After a value-moving token CPI (Transfer, TransferChecked, MintTo, MintToChecked) into any account, a balance snapshot taken before the CPI, or a value computed from one, may only be compared with a post-CPI reload or a constant, subtracted from a reload to form a delta (after.checked_sub(before)), or added to such a delta. A custody-named transfer destination (vault, custody, reserve, or pool) must also be read both before and after every transfer, with no other CPI in between.

Why this matters. Token-2022 transfer fees can make the amount received differ from the amount requested, so a balance read before the CPI no longer describes the account after it. Accounting from the requested amount or a pre-CPI snapshot rather than the observed balance delta credits the protocol with tokens it never received.

Blessing an exception. Reload the destination with amount() after the CPI and account from the delta after.checked_sub(before), or verify the arrival with if after != expected. Comparing the snapshot against a constant (if prior == 0) records a fact about the earlier state and is accepted as is. A static invoke() called on a builder bound to the legacy pinocchio_token program is exempt because that program cannot charge a fee; any other exception needs a narrowly scoped #[allow] with the reason the snapshot is still correct. When the account comes from a &mut self accessor, bind it once and use that binding for the reads and the transfer: a read through a let binding and a transfer into a separate inline call are not matched. Give each result of a hand-written cursor its own binding name: rebinding let acct = cursor.take()?; under the same name is treated as one account.

require_program_check_before_cpi

Default level: deny

Contract. Validate a dynamic CPI target with assert_address(), assert_addresses(), or assert_program() against a compile-time program ID before invoking it.

Why this matters. A dynamic program argument controls the CPI target. Without verifying that exact argument, an attacker substitutes a malicious program. An instruction argument is not a trusted expected ID — comparing two attacker-controlled values proves consistency, not authenticity — and success-side adapters such as map() can replace the validated binding before execution continues.

Blessing an exception. Call assert_address(), assert_addresses(), or assert_program() against a compile-time ID on every continuing path before the invocation and do not discard the result. There is no sound way to bless an unverified dynamic CPI target: the check is the entire security property. Use a hardcoded program ID type when the target is in fact fixed.

require_reason_for_duplicate_remaining_accounts

Default level: deny

Contract. Document why a field opts out of distinctness with #[pina(remaining, distinct = false)].

Why this matters. Opting out of the duplicate-address check reintroduces the duplicate mutable-account vulnerability. The doc comment forces the author to state the reason, which is the only thing distinguishing a deliberate exception from a mistake.

Blessing an exception. Write the doc comment. This lint is the blessing mechanism: it asks for an explanation rather than forbidding the pattern, so an #[allow] would remove the only recorded justification.

require_sysvar_assert_before_sysvar_use

Default level: deny

Contract. Call assert_sysvar() on an account before reading it as a sysvar.

Why this matters. A sysvar account is a specific address. Reading an account that merely has a sysvar-shaped layout without checking its address lets an attacker substitute data of their choosing for the clock, rent, or another sysvar.

Blessing an exception. Assert the sysvar kind on the account. The assertion derives the expected address from the canonical sysvar ID, so it is strictly stronger than comparing an ID supplied by the caller; prefer it over an #[allow] even where the surrounding code looks sufficient.

require_type_assert_before_zero_copy_cast

Default level: deny

Contract. Convert account data through a guard-backed Pina conversion instead of a raw zero-copy cast.

Why this matters. A raw cast reinterprets account bytes as a struct without proving the account is the expected type or that the data is large enough and aligned. The result is type cosplay: attacker-controlled bytes read as trusted fields.

Blessing an exception. Use the guard-backed conversion — assert_type() on the account, or a typed loader such as TokenAccountRef::from_account_view() — which checks the account type and length and keeps the borrow alive. Do not bless a raw cast on account data: the check it skips is what makes reading the fields sound.

require_writable_before_account_resize

Default level: deny

Contract. Call assert_writable() on an account before resizing it.

Why this matters. Writing to an account the transaction did not mark writable fails at runtime, and the resize is a write. The check also documents that the instruction intends to change the account’s size, which a reviewer and the client both need to know.

Blessing an exception. Assert writability on the account before the resize. A mutable fixed field parsed through AccountsCursor::next_mut already validated writability, so no exception is needed there; reach for #[allow] only on a path whose writability is established by a construct the lint cannot follow, and name it.

require_zeroed_before_close

Default level: deny

Contract. Zero an account’s data before closing it: close with close_account_zeroed(), or clear the whole buffer with account.try_borrow_mut()?.fill(0); before close_with_recipient() or close().

Why this matters. A closed account’s lamports are gone but its data survives until the account is reused. A later instruction that reads before writing sees the previous contents, so stale data can be reinterpreted as valid state.

Blessing an exception. Zero the account before closing. Prefer Pina’s close_account_zeroed(), which zeroes and closes as one operation and cannot be reordered. There is no sound reason to close a Pina account without zeroing it.

Production Readiness

Pina supplies program-building primitives. It does not make an application safe merely because the application compiles, passes the framework tests, or follows an example. Before deploying a program that controls valuable assets, define its economic invariants, test them at the transaction boundary, and obtain an independent security review.

What the examples prove

Examples have deliberately narrow scopes. They demonstrate framework APIs, account layouts, validation order, CPI construction, IDL extraction, or compatibility with an upstream test case. They are not audited applications.

In particular:

  • staking_rewards_program proves pool and position account creation, authority checks, ATA validation, and checked bookkeeping. Deposit and withdraw move real stake through the pool’s stake vault and credit the observed vault delta; claim releases real reward tokens from the reward vault under the SetRewardIndex reserve gate. It still defines no reward emission schedule and nothing refills the reward vault.
  • vesting_program proves schedule-state creation, PDA and ATA validation, and claim/cancel bookkeeping. It reads the Clock sysvar, funds the vault atomically at initialization, releases vested tokens through PDA-signed transfers, and settles the vested-but-unclaimed entitlement to the beneficiary on cancellation. It has no amendment path.
  • A passing native test does not prove that an SBF-backed test ran. Some example E2E tests report a skip when the required program binary has not been built.

Use these programs as focused implementation samples. Do not deploy them unchanged or treat their instruction names as evidence that the corresponding economic operation occurred.

Application invariants

Write the invariants for each instruction before implementation. For an asset-bearing program, cover at least:

  • the authority allowed to initiate the transition;
  • the exact accounts, owners, mints, programs, and canonical PDAs accepted;
  • the assets that must move and the balances that must be conserved;
  • the state values that may change, including their monotonicity and bounds;
  • the allowed time window and the trusted time source;
  • rounding, precision, overflow, and zero-value behavior;
  • replay, duplicate-account, cancellation, pause, close, and migration behavior;
  • Token and Token-2022 compatibility, including extensions that the program accepts or rejects.

Treat the on-chain account layout and instruction data as a protocol ABI. Plan migrations before changing either one.

Verification gate

Before a public deployment:

  1. Test every instruction against the built SBF artifact. Make missing artifacts fail the production CI job instead of skipping tests.
  2. Add negative tests for every authority, owner, signer, writable, program-ID, mint, PDA, and account-aliasing constraint.
  3. Assert post-transaction token and lamport balances, not only program-owned bookkeeping fields.
  4. Test transaction atomicity: every rejected CPI or late validation failure must leave balances and state unchanged.
  5. Add boundary and property tests for arithmetic, time, capacity, and serialization rules.
  6. Pin the toolchain and dependencies used for the audited build, then reproduce the final SBF hash from a clean environment.
  7. Profile compute use on the SBF artifact with realistic account sizes and worst-case instruction inputs.
  8. Review upgrade authority, emergency controls, monitoring, and incident response before mainnet deployment.
  9. Commission an independent review of the application logic. Framework checks and repository CI are supporting evidence, not a substitute.

Staking completion checklist

staking_rewards_program now implements custody transfers between user accounts and the PDA-controlled stake and reward vaults, validates vault ownership, mint consistency, and canonical ATA derivation, credits the observed vault delta on deposit, enforces a reserve gate before every index increase, banks reward-debt settlement before every stake change, and distinguishes accrued, claimable, and paid rewards. A funded deployment still needs application-specific decisions and tests for:

  • a time-based reward-emission and precision model, plus a way to fund the reward vault;
  • pause, unpause, and administration: PoolState.paused is read by every value path but no instruction sets it, so the flag is inert as shipped;
  • position closure and pool shutdown, including what happens to banked rewards;
  • a documented initialization trust model: the pool PDA is a singleton per mint pair, so the first caller to InitializePool becomes the permanent administrator (tracked as issue #502);
  • adversarial deposits, withdrawals, claims, duplicate accounts, and depleted reward vaults against the funded schedule.

Vesting completion checklist

vesting_program now validates the clock sysvar and implements a precise cliff/linear-unlock formula, funds the vault atomically during initialization with an observed-delta allocation, releases vested tokens through PDA-signed transfers, selects a cancellation policy that pays the beneficiary the vested-but-unclaimed entitlement, and rounds at schedule boundaries with double-claim protection. A funded deployment still needs application-specific decisions and tests for:

  • schedule amendment or beneficiary changes, if the product requires them: the instruction set is Initialize, Claim, and Cancel;
  • revocation policy beyond the linear curve, and whether the administrator may cancel after the schedule ends;
  • recovery behavior when the beneficiary ATA cannot be created;
  • adversarial claims before the cliff, after cancellation, at exact boundaries, and against substituted vaults or mints.

The Security Model documents framework-level invariants. The CI and Releases page describes the repository’s verification layers; an application should adopt equivalent gates for its own program logic.

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, including the pina_lints crate that holds every Pina lint.
  • security:pina-lint runs every registered Pina lint over all example programs and secure security fixtures. It builds the workspace pina_lint_driver and runs cargo with it as RUSTC_WORKSPACE_WRAPPER.
  • The Security Lints reference documents each rule, compliant patterns, and heuristic limitations.

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:sync after changing provider blocks to refresh all consumer blocks.
  • Run docs:check (or verify:docs) in CI to ensure docs stay synchronized.

Dependency/tooling updates

update:deps

Codama/IDL workflow

# Generate IDLs and clients for all examples.
codama:idl:all

# Generate Rust + CPI + JS + Dart clients.
codama:clients:generate

# Generate one project's configured clients.
pina generate

# Run the complete generation and validation 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:deny runs policy checks (license allow-list, source restrictions, dependency bans). CI exposes it as the dedicated cargo-deny job.
  • security:audit runs RustSec vulnerability checks over Cargo.lock.
  • security:zizmor audits GitHub Actions workflows and composite actions for security anti-patterns. CI exposes it as the dedicated zizmor job.
  • verify:security runs all of the checks above.

Coverage

Generate coverage locally for Pina’s runtime, CLI, Codama renderer, and profile codec fixtures:

coverage:all

This produces an LCOV report at target/coverage/lcov.info.

Bit-precise verification

Kani proves bounded safety and correctness properties over Pina’s parsers, lamport arithmetic, resize planning, fixed PinaPod validation, CPI metadata, and compact account layouts. Compact coverage includes size checks, valid initialization, patch preflight, grow and shrink ordering, and rejected updates that leave bytes and lamports unchanged:

# Fast proofs intended for every pull request.
devenv --profile kani shell -- test:kani:quick

# Heavier compact patch and layout state-machine proofs.
devenv --profile kani shell -- test:kani:compact

# Every proof harness.
devenv --profile kani shell -- test:kani

Kani is provided by the pinned ifiokjr/nixpkgs devenv input. Compact proofs use explicit unwind bounds. A successful result applies to the capacities and operation sequences encoded by each proof.

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:clippy
  • lint:format
  • verify:docs
  • security:pina-lint, security:audit, and security:npm-audit
  • security:deny in a dedicated cargo-deny job
  • security:zizmor in a dedicated zizmor job
  • test:all (workspace Rust tests, standalone fuzz-target compilation, and npm package tests)
  • test:npm-packages (scoped package metadata, native-target coverage, launchers, and skill installation)
  • test:kani:quick in a dedicated job for parser, arithmetic, compact-sizing, fixed-layout, and CPI invariants
  • test:kani:compact in a separate job for bounded compact-patch state machines, initialization, and failed-update rollback
  • feature-matrix for pina across 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)
    • compact-only (build:pina:compact-only + test:pina:compact-only + doc:pina:compact-only)
    • account-resize-only (build:pina:account-resize-only + test:pina:account-resize-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 (regenerate codama/idls and the Rust, JavaScript, and Dart clients; validate every output; and fail on any diff)
  • windows-cli (portability: the pina_cli test suite on x86_64-pc-windows-msvc)
  • pina-test (verify:pina-test for the published Surfpool test harness)
  • fuzz (test:fuzz:smoke for every fuzz target, with artifacts uploaded)
  • miri (test:miri zero-copy regressions for loader guards and token helpers)
  • cargo build --locked
  • cargo build --all-features --locked

Separate PR workflows also verify:

  • surfpool builds each example SBF program and exercises its runtime guards through the Surfpool SDK
  • performance for instruction compute units, every example program’s build size and static CU estimate, CLI timings, and core host timings against the PR base

The main CI workflow also runs release-publish on every pull request. When a PR contains releaseable changesets, the job creates the same release commit as the production release workflow and keeps that commit local to the runner. Registry readiness and a publish dry-run both select every package from its embedded release record, and CI requires their package sets to match so a newly added package cannot be omitted by a maintained allowlist. Cargo cannot completely verify dependent crates until their same-release dependencies exist in crates.io; Monochange plans those packages and publishes them in dependency order during the real release. Prepared release PRs are checked directly. Pull requests without a publishable release keep the job visible but skip the preflight explicitly.

This keeps code quality, behavior, documentation build health, feature-flag compatibility, and performance visibility aligned.

Surfpool example security checks

test:surfpool builds every current example program and starts a fresh SDK-managed Surfpool instance for each one. Fresh instances are required because several Anchor-parity fixtures intentionally share a program ID. The harness has an explicit inventory assertion: adding an example crate or generated IDL without adding it to the test matrix fails immediately. A missing .so artifact also fails immediately; there are no best-effort skips.

Every program is deployed at its declared ID and is exercised with a malformed discriminator and an otherwise-valid instruction sent at a different deployment address. Every program also has an explicit expected entrypoint result: either a successful invocation or its exact expected ProgramError after dispatch (for example, NotEnoughAccountKeys or a documented custom error). Stateful examples additionally receive attacker-controlled readonly account metadata and must return their expected runtime guard error. Negative assertions use Surfpool simulation logs and a returned InstructionError, so an RPC, build, or deployment failure cannot satisfy them.

Runtime guardSurfpool adversarial case
Discriminator/data validationEvery example rejects an unknown discriminator before any state transition.
Program-ID bindingEvery example rejects its own ELF when it is deployed at an attacker-controlled address.
Required account boundaryEvery selected IDL entrypoint with required accounts rejects an omitted account list.
Stateful account metadataState-changing examples reject attacker-controlled readonly account sets with a runtime access error.
Signer authorizationhello_solana_program rejects an unsigned user and accepts the same user only when marked as a signer.
Writable and alias checksduplicate_mutable_accounts_program rejects both a duplicate mutable alias and a non-writable account.
Program address allowlistdeclare_program rejects an arbitrary account in place of its expected external program.
Owner constraintsystem_accounts_program rejects an account explicitly created with a non-System owner.
Sysvar address validationsysvar_checks_program rejects ordinary accounts substituted for Clock, Rent, and Stake History.
Authority-bound PDA resizeaccount_realloc_program proves initialize/grow/shrink for its owner and rejects an unrelated signer, a forged typed account, and duplicate resize targets without data mutation.
Compact account lifecyclecompact_accounts_program proves header-only creation, atomic patch growth and shrink, exact rent adjustment, and rollback for bounds and authority failures.

The broader Pina examples also run their purpose-built Mollusk, LiteSVM, and Quasar tests in test:program-e2e; these cover PDA derivation, ownership, token-account, arithmetic/range, initialization, and unauthorized-mutation flows that need program-specific state setup. Surfpool complements those tests with a full, deployed SBF boundary check. It provides evidence that the listed invariants hold for the tested attacks; it is not a proof that no other attack exists.

Previously-tracked audit finding

An earlier revision of the security/06-duplicate-mutable-accounts/secure fixture checked distinct, program-owned balances but did not require that its signer matches the source balance’s stored owner, so an unrelated signer could debit a victim’s logical balance into an attacker’s destination. That authorization invariant is now enforced: validate_source_authority rejects a signer that does not match the source balance’s stored owner with LedgerError::UnauthorizedSigner, and dedicated regression tests cover both the unauthorized-transfer rejection and the legitimate owner path.

Performance regression policy

The performance workflow checks out the pull-request base in a sibling worktree. Four jobs run in parallel and update one sticky pull-request comment:

  • Instruction CU from the ignored Surfpool suites, simulated against copied base and head ELF files.
  • Static pina profile --json estimates and build sizes for every top-level example with a bpf-entrypoint feature.
  • Hyperfine timings for representative CLI commands and median host timings from crates/pina/tests/benchmarks.rs.

The report names the latest release reachable from the base. When that release is the base commit, the displayed comparison is also release-to-head. Otherwise the tag is context and timings remain base-to-head so the workflow avoids a third build.

The inventories are discovered instead of maintained by hand. A new example or instruction appears as a new baseline with its current result. Future pull requests compare against it. A missing head profile, ELF, or instruction measurement fails CI.

The compute-unit policy is:

  • warn when total_cu increases by at least +250 CU and +5.0%
  • fail when total_cu increases by at least +500 CU and +10.0%
  • decreases are positive and increases are negative
  • smaller static increases remain visible but do not fail the threshold gate
  • instruction runtime increases and every byte of ELF growth fail unless a matching reviewed exception permits the increase

Notes:

  • instruction cases use real transaction simulation through Surfpool; static profiles complement them with whole-program coverage and binary sizes
  • reviewed exceptions use staticCuApprovals, runtimeCuApprovals, or binarySizeApprovals, keyed by program name or instruction case ID
  • each exception records the full PR baseRevision, its measured base, the approved maximum head, and a reason explaining the accepted trade-off; the comparison must match both the base commit and measured value
  • an exception expires when its PR merges or the base changes; a CU exception never permits binary growth, and a size exception never permits a CU increase
  • update scripts/compute-unit-policy.json only for exclusions, thresholds, or explicitly reviewed exceptions; do not add new examples to an allowlist

For example, a reviewed instruction increase can be recorded as:

{
	"runtimeCuApprovals": {
		"example/instruction": {
			"baseRevision": "0123456789abcdef0123456789abcdef01234567",
			"base": 1000,
			"head": 1050,
			"reason": "Maintainer approved the additional validation cost."
		}
	}
}

CI and report:cu:compare:main supply --base-revision automatically. A direct comparison must pass the same full base commit ID for an exception to apply. Without it, increases receive the ordinary policy checks.

Local reproduction:

devenv shell -- profile:cu:tracked
devenv shell -- report:cu:compare:main

The local comparison writes artifacts to target/cu/, including a Markdown summary, copied ELFs, and machine-readable JSON.

See Compute-unit performance for the exact PinaPod v0.2 migration results and approval rationale.

Coverage

The coverage workflow runs focused coverage with cargo llvm-cov and publishes an LCOV artifact:

  • Command: coverage:all
  • Artifacts: target/coverage/lcov.info and target/coverage/pina-test.info
  • Optional upload: Codecov (fail_ci_if_error: true, so a failed upload fails the job)

Docs publishing

The docs-pages workflow publishes the mdBook to GitHub Pages:

  • Trigger: pushes to main that touch docs + GitHub Release published
  • Build command: docs:build (output in docs/book)
  • Deploy target: GitHub Pages (https://pina-rs.github.io/pina/)

CLI and npm releases

The publish workflow builds and uploads the pina CLI binary for all supported platforms on release tag pushes (v*):

  • Trigger: tag push v* (created by the release-pr workflow after a release PR merges)
  • Build scope: the pina CLI (crates/pina_cli), plus the prebuilt pina_lint_driver on targets whose runner can host the matching nightly toolchain
  • Artifacts: pina-<target>-<tag> archives containing pina and, where available, pina_lint_driver, with sha256/sha512 checksums, attested with build provenance

The same workflow builds, uploads, and attests the CLI archives — including the prebuilt pina_lint_driver, whose lints are statically compiled in from pina_lints — then publishes the crates. pina lint runs the driver bundled next to the CLI, so no separate tool or lint-bundle release jobs remain in the workflow. The driver cannot be cross-compiled (rustc-dev ships compiler libraries only for the toolchain’s own host), so targets without a matching runner ship CLI-only archives; on those platforms pina lint fails with guidance toward the prebuilt channels or PINA_LINT_DRIVER_PATH.

crates/pina_cli/lints.json (schema version 3) is the catalog of lint names and default levels used to validate the [lints] configuration in pina.toml. A test in pina_lints keeps the catalog in sync with the registered lints.

After attestation, the publish job downloads those same archives and fills the platform-specific npm packages. @pina-rs/cli uses optional dependencies to install the matching native package without compiling Rust. The release target and npm package matrices are checked one-to-one for:

  • macOS arm64 and x64
  • Linux arm64 and x64 with glibc or musl
  • Windows arm64 and x64
  • FreeBSD x64

The same trusted-publishing workflow also publishes @pina-rs/codama-nodes and @pina-rs/skill. Dry-run package inspection verifies the CLI launchers, native binaries, Codama CommonJS/ESM/type entrypoints, and skill runtime files before any registry write.

Release workflow

Use monochange for changelog/release management:

monochange run change
monochange run release
monochange step publish-packages

Keep changeset descriptions explicit and user-impact focused.

First-time packages

A new crates.io crate or npm package must exist before registry-side trusted publishing can be configured. Before its first real release, a registry owner should run monochange step placeholder-publish --dry-run --package <package-id>, publish the 0.0.0 placeholder with the same command without --dry-run, then configure repository pina-rs/pina, workflow publish.yml, and environment publisher as its trusted publisher. The placeholder both prevents name squatting and lets PR publication preflight validate later versions before the release workflow obtains an OIDC token.