Skip to content

Starframe

Starframe is a high-performance Solana/SVM program framework that works on Zink without a fork or compatibility layer. It is an Anchor alternative for teams that want stronger type-level account validation, zero-copy account access, typed PDA seeds, Codama IDL generation, and lower compute-unit usage.

Anchor is still fully supported on Zink and remains the most familiar choice for many teams. Starframe is worth evaluating when your program is compute-sensitive, validates many accounts, performs frequent token CPIs, or benefits from modeling account constraints as Rust types instead of framework-specific attribute strings.

Upstream-compatible

Starframe builds standard sBPF programs. A Starframe program deploys to Zink with the same cargo build-sbf and solana program deploy flow used for native Rust programs.

Why use Starframe?

AreaStarframe approachWhy it matters
Account dataZero-copy account access via bytemuck and PinocchioAvoids Borsh deserialize/serialize overhead on hot paths
ValidationTrait-based account modifiers such as Signer<Mut<SystemAccount>>, Init, Seeded, and ValidatedAccountMoves account-shape errors into Rust's type system
PDAsReusable GetSeeds structs and SeedsWithBumpMakes seed layouts explicit, typed, and reusable across validation and CPI signing
CPIsTyped CPI builders, including SPL helpers from star_frame_splReduces boilerplate and mismatched-account mistakes
IDLsCodama IDL generation behind an idl featureProduces client-facing interface metadata without pulling IDL code into production builds
MigrationCompatible discriminators by defaultKeeps instruction/account discriminators familiar while changing the framework internals

The Starframe documentation benchmarks show large CU and binary-size reductions versus equivalent Anchor examples, especially for typed account reads, multi-account validation, account initialization, and token-account-heavy instructions. Treat those numbers as workload-dependent: benchmark your own program before promising exact savings.

When to choose Anchor vs. Starframe

Choose Anchor when you want the largest ecosystem, maximum examples, mature TypeScript client ergonomics, and team familiarity.

Choose Starframe when you care more about:

  • minimizing compute units in account-heavy instructions;
  • zero-copy state layouts with explicit fixed-size data;
  • reusable typed PDA seeds;
  • composable account validation wrappers;
  • leaner binaries;
  • Codama IDLs and generated clients;
  • migrating an Anchor-style program while tightening type safety.

Both frameworks produce normal SVM programs. The Zink-specific part is still just the RPC endpoint and deployment target.

Install and scaffold

Starframe currently requires Rust 1.84.1+.

bash
rustc --version
solana --version
cargo build-sbf --version

Install the CLI from crates.io:

bash
cargo install star_frame_cli
sf --help

If you need the newest source version:

bash
cargo install --git https://github.com/staratlasmeta/star_frame star_frame_cli --locked

Scaffold a program:

bash
sf new my_program
cd my_program

The generated project is a small counter program with this shape:

text
my_program/
├── .cargo/config.toml          # sets SBF_OUT_DIR = "target/deploy"
├── Cargo.toml                  # Starframe, Borsh, bytemuck, and Mollusk deps
├── README.md
├── src/
│   ├── lib.rs                  # program declaration, instruction set, errors
│   ├── states.rs               # counter account, seeds, validation
│   ├── instructions/
│   │   ├── initialize.rs
│   │   └── increment.rs
│   └── tests/
│       └── counter.rs          # Mollusk test and IDL generator
└── target/deploy/
    └── my_program-keypair.json # generated program keypair

The scaffold writes the generated program keypair's public key into the program declaration. Keep that keypair with the project if you intend to upgrade the same deployed program later.

Cargo layout

Use the standard SVM library crate layout. The current Starframe scaffold includes a small dependency pin for cargo build-sbf compatibility with Solana's bundled Cargo version.

toml
[lib]
crate-type = ["cdylib", "lib"]

[features]
no_entrypoint = []
idl = ["star_frame/idl"]

[profile.release]
overflow-checks = true
lto = "thin"
codegen-units = 1

[dependencies]
az = "=1.2.1"
borsh = { version = "1.5.7", features = ["derive"] }
bytemuck = { version = "^1.22.0", features = ["extern_crate_std", "min_const_generics", "derive"] }
star_frame = { version = "0.29.0" }

[dev-dependencies]
mollusk-svm = "=0.7.0"
solana-account = "3.0.0"

Build, test, and deploy with standard tools:

bash
cargo build
cargo test
cargo build-sbf
cargo test-sbf
solana config set --url https://testnet-rpc.z.ink
solana program deploy target/deploy/my_program.so --program-id target/deploy/my_program-keypair.json

Generate a Codama IDL from the test-only idl feature when you need client bindings:

bash
cargo test --features idl -- generate_idl

Scaffolded counter walkthrough

This is the same simple-account pattern used throughout the Zink docs, written with Starframe instead of Anchor.

Program declaration

src/lib.rs declares the program, instruction set, and program errors:

rust
use instructions::*;
use star_frame::prelude::*;
mod instructions;
pub mod states;

#[cfg(test)]
mod tests;

#[derive(StarFrameProgram)]
#[program(
    instruction_set = MyProgramInstructionSet,
    id = "<GENERATED_PROGRAM_ID>"
)]
pub struct MyProgramProgram;

#[derive(InstructionSet)]
pub enum MyProgramInstructionSet {
    Initialize(InitializeCounter),
    Increment(Increment),
}

#[star_frame_error]
pub enum MyProgramError {
    #[msg("Incorrect authority")]
    IncorrectAuthority,
}

Important details:

  • #[derive(StarFrameProgram)] generates the program entrypoint.
  • InstructionSet defines the dispatch surface.
  • #[star_frame_error] turns a normal Rust enum into program errors with stable messages.

Account state and typed seeds

src/states.rs keeps the zero-copy account, PDA seeds, and custom validation together:

rust
use crate::MyProgramError;
use star_frame::prelude::*;

#[derive(Debug, GetSeeds, Clone)]
#[get_seeds(seed_const = b"COUNTER")]
pub struct CounterAccountSeeds {
    pub authority: Pubkey,
}

#[zero_copy(pod)]
#[derive(Default, Debug, Eq, PartialEq, ProgramAccount)]
#[program_account(seeds = CounterAccountSeeds)]
pub struct CounterAccount {
    pub authority: Pubkey,
    pub count: u64,
}

pub struct Authority(pub Pubkey);

impl AccountValidate<Authority> for CounterAccount {
    fn validate_account(self_ref: &Self::Ptr, arg: Authority) -> Result<()> {
        ensure_eq!(
            &self_ref.authority,
            &arg.0,
            MyProgramError::IncorrectAuthority
        );
        Ok(())
    }
}

Details to notice:

  • #[zero_copy(pod)] keeps account state fixed-size and directly readable from the account buffer.
  • CounterAccountSeeds gives the PDA seed layout a reusable Rust type.
  • ValidatedAccount<CounterAccount> can call the custom AccountValidate implementation before instruction logic runs.
  • For zero-copy account fields, prefer fixed-size types. Use u8 instead of bool when a flag must live in a #[zero_copy(pod)] account.

Initialize instruction

src/instructions/initialize.rs creates the counter PDA and writes initial state:

rust
use crate::states::*;
use star_frame::prelude::*;

#[derive(BorshSerialize, BorshDeserialize, Debug, InstructionArgs)]
pub struct InitializeCounter {
    #[ix_args(run)]
    pub start_at: Option<u64>,
}

#[derive(AccountSet, Debug)]
pub struct InitializeAccounts {
    #[validate(funder)]
    pub authority: Signer<Mut<SystemAccount>>,

    #[validate(arg = (
        Create(()),
        Seeds(CounterAccountSeeds { authority: *self.authority.pubkey() }),
    ))]
    #[idl(arg = Seeds(FindCounterAccountSeeds { authority: seed_path("authority") }))]
    pub counter: Init<Seeded<Account<CounterAccount>>>,

    pub system_program: Program<System>,
}

#[star_frame_instruction]
fn InitializeCounter(accounts: &mut InitializeAccounts, start_at: Option<u64>) -> Result<()> {
    **accounts.counter.data_mut()? = CounterAccount {
        authority: *accounts.authority.pubkey(),
        count: start_at.unwrap_or(0),
    };
    Ok(())
}

Init<Seeded<Account<CounterAccount>>> says this instruction creates a PDA account of the expected type. Create(()) handles the allocation flow, and #[validate(funder)] marks the mutable system account that pays rent.

The #[idl(...)] hint tells Codama how clients should derive the PDA when generating an IDL; keep those hints close to the account field they describe.

Increment instruction

src/instructions/increment.rs validates the authority before mutating the account:

rust
use crate::states::*;
use star_frame::prelude::*;

#[derive(BorshSerialize, BorshDeserialize, Debug, Copy, Clone, InstructionArgs)]
pub struct Increment;

#[derive(AccountSet, Debug)]
pub struct IncrementAccounts {
    pub authority: Signer,

    #[validate(arg = Authority(*self.authority.pubkey()))]
    pub counter: Mut<ValidatedAccount<CounterAccount>>,
}

#[star_frame_instruction]
fn Increment(accounts: &mut IncrementAccounts) -> Result<()> {
    let mut counter = accounts.counter.data_mut()?;
    counter.count += 1;
    Ok(())
}

A useful rule of thumb: in Anchor, the attribute often carries the constraint; in Starframe, the Rust type usually carries the constraint.

Testing and IDL generation

Projects created with sf new include a Mollusk test under src/tests/. The test uses generated Starframe client helpers to build instructions, initializes the counter PDA, increments it, and checks the serialized account state.

Run the normal test loop like this:

bash
# Native compile/tests
cargo test

# Build and test the deployable sBPF artifact
cargo build-sbf
cargo test-sbf

# Emit target/idl/<program>.json for clients
cargo test --features idl -- generate_idl

The scaffolded Mollusk test skips the program-binary check if target/deploy/<program>.so is missing. That keeps plain cargo test fast while cargo build-sbf && cargo test-sbf validates the compiled artifact. If an older Solana toolchain does not provide cargo test-sbf, rerun cargo test after cargo build-sbf; the scaffold also checks SBF_OUT_DIR and target/deploy directly.

Anchor pattern mapping

Program and instruction dispatch

Anchor places handlers in a #[program] module:

rust
#[program]
pub mod my_program {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>, amount: u64) -> Result<()> {
        ctx.accounts.counter.count = amount;
        Ok(())
    }
}

Starframe separates the dispatch enum from the handler implementation:

rust
#[derive(InstructionSet)]
pub enum MyInstructionSet {
    Initialize(Initialize),
}

#[derive(BorshSerialize, BorshDeserialize, Debug, InstructionArgs)]
pub struct Initialize {
    #[ix_args(run)]
    pub amount: u64,
}

#[star_frame_instruction]
fn Initialize(accounts: &mut InitializeAccounts, amount: u64) -> Result<()> {
    accounts.counter.data_mut()?.count = amount;
    Ok(())
}

This is slightly more explicit, but it gives the compiler more structure to validate.

Account constraints and PDAs

Anchor expresses most constraints as attributes on an accounts struct:

rust
#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(mut)]
    pub payer: Signer<'info>,

    #[account(
        init,
        payer = payer,
        space = 8 + Counter::INIT_SPACE,
        seeds = [b"counter", payer.key().as_ref()],
        bump,
    )]
    pub counter: Account<'info, Counter>,

    pub system_program: Program<'info, System>,
}

Starframe composes account wrappers and typed seed structs:

rust
#[derive(Debug, GetSeeds, Clone)]
#[get_seeds(seed_const = b"counter")]
pub struct CounterSeeds {
    pub authority: Pubkey,
}

#[derive(AccountSet)]
pub struct InitializeAccounts {
    #[validate(funder)]
    pub payer: Signer<Mut<SystemAccount>>,

    #[validate(arg = (
        Create(()),
        Seeds(CounterSeeds { authority: *self.payer.pubkey() }),
    ))]
    pub counter: Init<Seeded<Account<Counter>>>,

    pub system_program: Program<System>,
}

Custom validation

Use ValidatedAccount<T> when account validity depends on program-specific rules:

rust
pub struct ActiveProposal;

impl AccountValidate<ActiveProposal> for Proposal {
    fn validate_account(self_ref: &Self::Ptr, _arg: ActiveProposal) -> Result<()> {
        ensure!(self_ref.is_active == 1, ProposalError::Inactive);
        Ok(())
    }
}

#[derive(AccountSet)]
pub struct CastVoteAccounts {
    #[validate(arg = ActiveProposal)]
    pub proposal: Mut<ValidatedAccount<Proposal>>,
}

That pattern is especially clean for state machines: proposals, orders, escrows, positions, cooldowns, and any account whose state gates the instruction.

CPI and SPL tokens

For SPL Token CPIs, add the SPL helper crate:

toml
[dependencies]
star_frame_spl = { version = "0.29.0", features = ["token"] }

A token transfer CPI becomes a typed builder call:

rust
use star_frame_spl::token::{
    instructions::{Transfer, TransferCpiAccounts},
    Token,
};

Token::cpi(
    Transfer { amount },
    TransferCpiAccounts {
        source: *accounts.source.account_info(),
        destination: *accounts.destination.account_info(),
        owner: *accounts.owner.account_info(),
    },
    None,
).invoke()?;

If a PDA signs the CPI, reconstruct the seed components and pass signer seeds:

rust
let seeds_with_bump = SeedsWithBump {
    seeds: CounterAccountSeeds { authority },
    bump,
};
let signer_seeds = seeds_with_bump.seeds_with_bump();

Token::cpi(transfer, cpi_accounts, None)
    .invoke_signed(&[signer_seeds.as_slice()])?;

Store every seed component and the bump you will need later. A PDA cannot sign future CPIs unless the program can reconstruct the same seed tuple.

Practical use cases on Zink

Starframe fits particularly well for:

  1. Compute-sensitive protocols. Account-heavy instructions get expensive quickly; zero-copy reads and lean validation can keep transactions under budget.
  2. PDA-heavy state machines. Typed seeds help avoid drift between initialization, reads, closes, and signed CPIs.
  3. Token workflows. star_frame_spl gives typed CPI wrappers for SPL Token flows while preserving low-level control.
  4. Large account sets. Composable modifiers keep validation readable when an instruction touches many accounts.
  5. IDL-first clients. Codama IDLs are useful when you want generated clients or a typed interface contract outside Rust.
  6. Anchor migrations. Start by porting one instruction or one account family, benchmark it, and keep the surrounding deployment flow unchanged.

Production checklist

Before deploying a Starframe program to Zink:

  • Use Rust 1.84.1+ and pin toolchain versions in rust-toolchain.toml if your team needs reproducible builds.
  • Keep idl as a feature-gated development path, not a default production feature.
  • Prefer fixed-size zero-copy account fields; use u8 instead of bool in #[zero_copy(pod)] types.
  • Include Program<System> in account sets that create accounts; Init performs a System Program CPI.
  • Tag the rent payer with #[validate(funder)] and make it mutable: Signer<Mut<SystemAccount>>.
  • Store PDA seed components and bumps anywhere the PDA may need to sign a later CPI.
  • Regenerate Codama IDLs after changing the instruction or account surface.
  • Benchmark representative instructions against your Anchor or native implementation before relying on projected CU savings.
  • Coordinate Zink mainnet deployments with the relevant maintainers, just as you would for Anchor programs.

References

Zink is a general-purpose SVM network for programs, operators, and bridge integrations.