Appearance
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?
| Area | Starframe approach | Why it matters |
|---|---|---|
| Account data | Zero-copy account access via bytemuck and Pinocchio | Avoids Borsh deserialize/serialize overhead on hot paths |
| Validation | Trait-based account modifiers such as Signer<Mut<SystemAccount>>, Init, Seeded, and ValidatedAccount | Moves account-shape errors into Rust's type system |
| PDAs | Reusable GetSeeds structs and SeedsWithBump | Makes seed layouts explicit, typed, and reusable across validation and CPI signing |
| CPIs | Typed CPI builders, including SPL helpers from star_frame_spl | Reduces boilerplate and mismatched-account mistakes |
| IDLs | Codama IDL generation behind an idl feature | Produces client-facing interface metadata without pulling IDL code into production builds |
| Migration | Compatible discriminators by default | Keeps 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 --versionInstall the CLI from crates.io:
bash
cargo install star_frame_cli
sf --helpIf you need the newest source version:
bash
cargo install --git https://github.com/staratlasmeta/star_frame star_frame_cli --lockedScaffold a program:
bash
sf new my_program
cd my_programThe 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 keypairThe 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.jsonGenerate a Codama IDL from the test-only idl feature when you need client bindings:
bash
cargo test --features idl -- generate_idlScaffolded 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.InstructionSetdefines 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.CounterAccountSeedsgives the PDA seed layout a reusable Rust type.ValidatedAccount<CounterAccount>can call the customAccountValidateimplementation before instruction logic runs.- For zero-copy account fields, prefer fixed-size types. Use
u8instead ofboolwhen 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_idlThe 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:
- Compute-sensitive protocols. Account-heavy instructions get expensive quickly; zero-copy reads and lean validation can keep transactions under budget.
- PDA-heavy state machines. Typed seeds help avoid drift between initialization, reads, closes, and signed CPIs.
- Token workflows.
star_frame_splgives typed CPI wrappers for SPL Token flows while preserving low-level control. - Large account sets. Composable modifiers keep validation readable when an instruction touches many accounts.
- IDL-first clients. Codama IDLs are useful when you want generated clients or a typed interface contract outside Rust.
- 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 inrust-toolchain.tomlif your team needs reproducible builds. - Keep
idlas a feature-gated development path, not a default production feature. - Prefer fixed-size zero-copy account fields; use
u8instead ofboolin#[zero_copy(pod)]types. - Include
Program<System>in account sets that create accounts;Initperforms 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
- Starframe repository
- Starframe API docs
- Starframe example programs
- Quickstart — scaffold, build, test, and deploy the counter program
- Local Setup — install the shared SVM toolchain
- Testing & Localnet — run the scaffolded Mollusk test and local validator flows
- Deployment — deploy and upgrade the resulting
.soon Zink