I built the same token escrow (maker locks mint A, taker pays mint B, vault drains) in Anchor 1.0, Pinocchio 0.11, and Quasar 0.0.0 against a shared SPEC, then ran them through the same test harness on quasar-svm to get comparable CU numbers. The contract is intentionally simple (three instructions, one PDA, one vault ATA) so the framework overhead is the dominant variable rather than the business logic.
The contract
Three instructions: make(seed, amount_a, amount_b) creates an Offer PDA at ["offer", maker, seed_u64] and transfers amount_a of mint A into a vault ATA owned by the offer; take() transfers amount_b of mint B from taker to maker, drains the vault to taker, and closes both accounts; refund() returns vault contents to maker and closes. The Offer stores maker, both mints, the requested amount_b, and the PDA bump.
Account validation
The frameworks diverge most visibly in how they express the checks that every handler needs: signer verification, PDA derivation, mint/owner matching, ATA ownership.
Anchor declares all of this in an #[derive(Accounts)] struct where each field carries constraints (init, seeds, associated_token::mint, payer). The generated try_accounts deserializes, validates, and errors before your handler runs. For escrow the Make struct is ~20 lines of attributes and the handler body is ~15 lines of business logic. The cost: try_accounts stack-allocates every deserialized account inline, and with four Account<TokenAccount> (165 bytes each) the Take handler overflows the 4KB SBF stack frame. Fix is Box<Account<TokenAccount>> for the heavy fields, which moves them to the heap at the cost of an allocation per boxed account.
Pinocchio has no Accounts struct. You get &mut [AccountView] and destructure with a slice pattern:
let [maker, offer, mint_a, mint_b, maker_ata_a, vault, _rent, _token_prog, _sys_prog] = accounts
else { return Err(ProgramError::NotEnoughAccountKeys); }; Every check is explicit: maker.is_signer(), offer.address() == &expected_pda, owner comparisons against known program IDs. More code, but no hidden allocations and no generated code to debug through when something goes wrong. The close path is also simpler than expected: drain lamports and call AccountView::close(), the runtime zeros data at end-of-instruction. I initially tried system_program::Assign to flip the owner back to system before closing, which triggers UnbalancedInstruction since the runtime sees the lamport delta and owner change in the same instruction.
Quasar sits between: it has #[derive(Accounts)] with Anchor-like constraints but no lifetime parameters (account types are #[repr(transparent)] wrappers over AccountView, not borrows). Seeds are typed via #[seeds(b"offer", maker: Address, seed: u64)] on the struct definition, then referenced as Offer::seeds(maker, seed) in the constraint. The 1-byte discriminator (#[account(discriminator = 1)] vs Anchor’s 8) saves 7 bytes per PDA and a few CU on every access.
CPI ergonomics
Each framework wraps token transfers and ATA creation differently, which is where most of the escrow’s instruction code lives.
Anchor wraps everything in CpiContext:
let cpi_accounts = TransferChecked { from: vault, to: taker_ata, mint: mint_a, authority: offer };
transfer_checked(CpiContext::new_with_signer(token_program.key(), cpi_accounts, signer_seeds), amount, decimals)?; One Anchor 1.0 migration footgun: CpiContext::new now takes Pubkey (the program ID), not AccountInfo. Every CPI site changes from token_program.to_account_info() to token_program.key().
Pinocchio’s companion crate (pinocchio-token) gives you builder-style calls:
Transfer::new(maker_ata_a, vault, maker, amount_a).invoke()?; Behind the scenes this builds an InstructionView with the right InstructionAccount flags and calls cpi::invoke. The 0.11 rename from AccountMeta/Instruction/program::invoke to the new names only matters when you’re hand-encoding CPIs without the companion crate.
Quasar uses CpiCall::<N_ACCOUNTS, N_DATA> with explicit const generics:
CpiCall::<4, 9>::invoke(token_program, &accounts, &data)?; The const generics eliminate runtime-sized allocations for the CPI account/data buffers, and the trade-off is that adding an account to a CPI means changing the const parameter (a compile error rather than a silent bug). Quasar also rejects duplicate account keys in a single CPI (same pubkey with different is_signer/is_writable flags), which Anchor and Pinocchio both allow. This bit during mint_to where the mint authority PDA needed to appear as both signer and writable.
The declare_id! trap (Quasar-specific)
Quasar’s declare_id! accepts any base58 string and compiles. A placeholder like "11111111111111111111111111111111" doesn’t fail until runtime: init creates the PDA with &crate::ID as owner, then the next write fails with IllegalOwner because the executing program’s real pubkey doesn’t match. Anchor catches this at build time (refuses to proceed when declare_id! disagrees with the keypair). Pinocchio doesn’t have the problem since program_id is a runtime argument, not a compiled-in constant.
Fix: build once to generate the keypair, paste its base58 into declare_id!, rebuild.
Testing across frameworks
Quasar’s #[program] macro emits a non-standard 2-arg entrypoint that reads the data length from *(instruction_data.sub(8) as *const u64). Standard invokers (litesvm, solana_program_runtime) pass one arg, so the second register is garbage; instruction_data.sub(8) underflows to 0xfffffffffffffff8 and the program segfaults after consuming 1 CU. This forced a migration from litesvm to quasar-svm, which is Quasar’s own in-process SVM that handles the 2-arg ABI. All three framework implementations now run against quasar-svm for comparable results.
Compute benchmarks
| Instruction | Anchor | Pinocchio | Quasar |
|---|---|---|---|
make | 55,390 | 31,813 | 15,366 |
take | 45,433 | 15,802 | 16,278 |
refund | 25,240 | 10,006 | 10,384 |
Anchor is 2-3.5x more expensive across the board, which isn’t surprising given the deserialization and constraint-checking machinery try_accounts runs before the handler. Pinocchio and Quasar converge on take and refund (both ~10-16k CU), but Quasar’s make is half of Pinocchio’s since the typed-seeds PDA derivation and zero-copy init path skip work that Pinocchio’s explicit find_program_address + manual CreateAccount CPI pay for at runtime.
quasar profile on the make instruction breaks this down further: 4,520 CU total inside the program, with 98% (4,431 CU) in the entrypoint and 2% (89 CU) unattributed runtime overhead (account deserialization from the SVM input buffer, which Quasar doesn’t emit symbols for). The gap between 4,520 and the benchmark’s 15,366 is entirely CPIs: CreateAccount, CreateAta, and Transfer execute in the system/token programs and their CU gets attributed to those callees. So Quasar’s own dispatch + validation + instruction logic for make is ~4.5k CU, with the remaining ~11k in external programs. That’s the zero-copy payoff: no deserialization pass, no heap allocation, just pointer casts and validation.
Binary sizes tell the same story:
| Framework | .so size |
|---|---|
| Anchor | 230.7 KB |
| Pinocchio | 23.3 KB |
| Quasar | 39.3 KB |
| Quasar (upstream) | 101.7 KB |
Anchor’s binary is 10x Pinocchio’s, mostly from the IDL, constraint-checking codegen, and SPL crate dependencies pulled in at link time. Quasar lands at ~1.7x Pinocchio with its standard Solana target, paying for macro-generated validation but nothing like Anchor’s full runtime. The upstream target comes in at 101.7 KB, which is odd since Quasar’s docs claim it should produce smaller binaries. Haven’t dug into why.
Most of the debugging was toolchain, not contracts
Time sinks in rough order:
- Old
solanaCLI on PATH bundling a rustc that predates edition 2024. Fix: reinstall Anza CLI fromrelease.anza.xyz. - Cargo lockfile v4 not parseable without
RUSTC_BOOTSTRAP=1 CARGO_UNSTABLE_NEXT_LOCKFILE_BUMP=true, encoded intobuild.sh. bpfel-unknown-noneis Tier 3, sorustup target addfails silently. Needrustup component add rust-src --toolchain nightlyfor-Zbuild-stdto build core from source. Quasar’s install page says the wrong thing here.cargo build-bpfis a project-local alias in.cargo/config.toml, not a system binary. Quasar’s template generates it; scaffolding by hand means copying fromexamples/upstream-vault/.cargo/config.toml.
Where this lands
Anchor’s account-validation-as-declarations model eliminates the largest class of Solana bugs (missing signer checks, wrong PDA seeds, unchecked owners) at the cost of stack pressure and higher CU, and the IDL generation is genuinely free with clients benefiting immediately.
Pinocchio’s companion crates cover common SPL operations well enough that the “write everything by hand” reputation is overstated for standard token contracts, but the ergonomics collapse when you need Token-2022 extensions or anything without a companion crate since you’re hand-encoding CPI instruction data against undocumented byte layouts.
Quasar’s type-level improvements (no lifetimes, typed seeds, 1-byte discriminators, const-generic CPI) point at where the ergonomics should converge, though the runtime traps (declare_id, custom SVM requirement, duplicate-key CPI rejection) cost real debugging time today and a 1.0 release will likely smooth them out.
Next up: pushing all three into Token-2022 transfer hooks, where hand-rolling wire formats without std gets interesting.
Code for the contracts is available on github: https://github.com/Wally869/SolanaEscrow.