Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/magicblock-labs/magicblock-engine-examples/llms.txt

Use this file to discover all available pages before exploring further.

A lightweight counter program using the Pinocchio framework with Ephemeral Rollups. This example demonstrates a more efficient alternative to Borsh serialization, using manual serialization with fixed-size types for reduced compute and size overhead.

What You’ll Learn

  • How to use Pinocchio for lightweight Solana programs
  • How to implement manual serialization without Borsh
  • How to delegate accounts using the Pinocchio SDK
  • How to work with fixed-size arrays and primitives
  • How to minimize program size and compute units

Program Structure

The Pinocchio counter program includes the following instructions:
  • 0: InitializeCounter - Initialize a counter PDA to 0 (payload: bump u8)
  • 1: IncreaseCounter - Increase counter by specified amount (payload: bump u8 + increase_by u64)
  • 2: Delegate - Delegate the counter to Ephemeral Rollups (payload: bump u8)
  • 3: CommitAndUndelegate - Commit and undelegate the counter
  • 4: Commit - Commit changes to base layer
  • 5: IncrementAndCommit - Increment and commit in one instruction (payload: bump u8 + increase_by u64)
  • 6: IncrementAndUndelegate - Increment and undelegate in one instruction (payload: bump u8 + increase_by u64)

Software Requirements

SoftwareVersionInstallation Guide
Solana2.3.13Install Solana
Rust1.85.0Install Rust
Node24.10.0Install Node

Build and Test

1

Build the program

cargo build-sbf
2

Run tests

Run tests with logging enabled:
cargo test-sbf --features logging

Key Differences from Rust Counter

No Borsh

Uses manual serialization with to_le_bytes() and from_le_bytes() for simplicity

No Vec

All types use fixed-size arrays or primitives

Pinocchio Framework

Leverages Pinocchio’s lightweight instruction handling

Direct State Management

Simple Counter struct with manual memory management

Program Implementation

State Definition

The counter state is a simple struct with manual serialization:
use pinocchio::error::ProgramError;

#[repr(C)]
pub struct Counter {
    pub count: u64,
}

impl Counter {
    pub const SIZE: usize = 8;

    pub fn load_mut(data: &mut [u8]) -> Result<&mut Self, ProgramError> {
        if data.len() < Self::SIZE {
            return Err(ProgramError::InvalidArgument);
        }
        let ptr = data.as_mut_ptr() as *mut Self;
        // Verify alignment
        if (ptr as usize) % core::mem::align_of::<Self>() != 0 {
            return Err(ProgramError::InvalidAccountData);
        }
        // Safety: caller ensures the account data is valid for Counter.
        Ok(unsafe { &mut *ptr })
    }
}
Using #[repr(C)] ensures the struct has a predictable memory layout, allowing direct pointer casting.

Initialize Counter

use pinocchio::{AccountView, Address, ProgramResult};
use pinocchio_system::instructions::CreateAccount;
use pinocchio::cpi::{Seed, Signer};

pub fn process_initialize_counter(
    program_id: &Address,
    accounts: &[AccountView],
    bump: u8,
) -> ProgramResult {
    let [initializer_account, counter_account, _system_program] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    let bump_seed = [bump];
    let counter_pda = counter_address_from_bump(program_id, initializer_account, bump)?;

    if counter_pda != *counter_account.address() {
        return Err(ProgramError::InvalidArgument);
    }

    // Create counter account if it doesn't exist.
    if counter_account.lamports() == 0 {
        let rent_exempt_lamports = 1_000_000;

        let create_account_ix = CreateAccount {
            from: initializer_account,
            to: counter_account,
            lamports: rent_exempt_lamports,
            space: Counter::SIZE as u64,
            owner: program_id,
        };

        let seed_array: [Seed; 3] = [
            Seed::from(b"counter"),
            Seed::from(initializer_account.address().as_ref()),
            Seed::from(&bump_seed),
        ];
        let signer = Signer::from(&seed_array);
        create_account_ix.invoke_signed(&[signer])?;
    }

    // Initialize counter to 0.
    let mut data = counter_account.try_borrow_mut()?;
    let counter_data = Counter::load_mut(&mut data)?;
    counter_data.count = 0;

    Ok(())
}

Increment Counter

pub fn process_increase_counter(
    program_id: &Address,
    accounts: &[AccountView],
    bump: u8,
    increase_by: u64,
) -> ProgramResult {
    let [initializer_account, counter_account] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    let counter_pda = counter_address_from_bump(program_id, initializer_account, bump)?;

    if counter_pda != *counter_account.address() {
        return Err(ProgramError::InvalidArgument);
    }

    let mut data = counter_account.try_borrow_mut()?;
    let counter_data = Counter::load_mut(&mut data)?;
    counter_data.count = counter_data
        .count
        .checked_add(increase_by)
        .ok_or(ProgramError::ArithmeticOverflow)?;

    Ok(())
}
Pinocchio uses AccountView instead of AccountInfo, providing a more lightweight abstraction.

Delegation Implementation

use ephemeral_rollups_pinocchio::instruction::delegate_account;
use ephemeral_rollups_pinocchio::types::DelegateConfig;

pub fn process_delegate(
    _program_id: &Address,
    accounts: &[AccountView],
    bump: u8,
) -> ProgramResult {
    let [initializer, pda_to_delegate, owner_program, delegation_buffer, delegation_record, delegation_metadata, _delegation_program, system_program, rest @ ..] =
        accounts
    else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };
    let validator = rest.first().map(|account| *account.address());

    let seed_1 = b"counter";
    let seed_2 = initializer.address().as_ref();
    let seeds: &[&[u8]] = &[seed_1, seed_2];
    let counter_pda = counter_address_from_bump(owner_program.address(), initializer, bump)?;

    let delegate_config = DelegateConfig {
        validator,
        ..Default::default()
    };

    if counter_pda != *pda_to_delegate.address() {
        return Err(ProgramError::InvalidArgument);
    }

    delegate_account(
        &[
            initializer,
            pda_to_delegate,
            owner_program,
            delegation_buffer,
            delegation_record,
            delegation_metadata,
            system_program,
        ],
        seeds,
        bump,
        delegate_config,
    )?;

    Ok(())
}

Commit and Undelegate

use ephemeral_rollups_pinocchio::instruction::{
    commit_accounts, commit_and_undelegate_accounts,
};

pub fn process_commit(_program_id: &Address, accounts: &[AccountView]) -> ProgramResult {
    let [initializer, counter_account, magic_program, magic_context] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    if !initializer.is_signer() {
        return Err(ProgramError::MissingRequiredSignature);
    }

    commit_accounts(
        initializer,
        &[*counter_account],
        magic_context,
        magic_program,
    )?;

    Ok(())
}

pub fn process_commit_and_undelegate(
    _program_id: &Address,
    accounts: &[AccountView],
) -> ProgramResult {
    let [initializer, counter_account, magic_program, magic_context] = accounts else {
        return Err(ProgramError::NotEnoughAccountKeys);
    };

    if !initializer.is_signer() {
        return Err(ProgramError::MissingRequiredSignature);
    }

    commit_and_undelegate_accounts(
        initializer,
        &[*counter_account],
        magic_context,
        magic_program,
    )?;

    Ok(())
}

TypeScript Client Usage

Initialize with Bump

Pinocchio counter requires passing the bump seed in the instruction data.
import { 
  Connection,
  getProgramDerivedAddress,
  getAddressEncoder,
} from '@solana/kit';
import * as borsh from "borsh";

const addressEncoder = getAddressEncoder();
const [counterPda, bump] = await getProgramDerivedAddress({
  programAddress: PROGRAM_ID,
  seeds: [
    Buffer.from("counter"),
    addressEncoder.encode(userPubkey)
  ],
});
const bumpBytes = Buffer.from([bump]);

const accounts = [
  { address: userPubkey, role: AccountRole.WRITABLE_SIGNER},
  { address: counterPda, role: AccountRole.WRITABLE },
  { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
];

const serializedInstructionData = Buffer.concat([
  Buffer.from(CounterInstruction.InitializeCounter, "hex"),
  bumpBytes,
]);

const initializeIx: Instruction = {
  accounts,
  programAddress: PROGRAM_ID,
  data: serializedInstructionData,
};

Delegate to Ephemeral Rollups

import { 
  DELEGATION_PROGRAM_ID,
  delegationRecordPdaFromDelegatedAccount,
  delegationMetadataPdaFromDelegatedAccount,
  delegateBufferPdaFromDelegatedAccountAndOwnerProgram,
} from "@magicblock-labs/ephemeral-rollups-kit";

const remainingAccounts = connection.clusterUrlHttp.includes("localhost")
  ? [{
      address: address("mAGicPQYBMvcYveUZA5F5UNNwyHvfYh5xkLS2Fr1mev"),
      role: AccountRole.READONLY
    }]
  : [{
      address: address("MAS1Dt9qreoRMQ14YQuhg8UTZMMzDdKhmkZMECCzk57"),
      role: AccountRole.READONLY
    }];

const accounts = [
  { address: userPubkey, role: AccountRole.WRITABLE_SIGNER},
  { address: counterPda, role: AccountRole.WRITABLE },
  { address: PROGRAM_ID, role: AccountRole.READONLY },
  {
    address: await delegateBufferPdaFromDelegatedAccountAndOwnerProgram(counterPda, PROGRAM_ID),
    role: AccountRole.WRITABLE
  },
  {
    address: await delegationRecordPdaFromDelegatedAccount(counterPda),
    role: AccountRole.WRITABLE
  },
  {
    address: await delegationMetadataPdaFromDelegatedAccount(counterPda),
    role: AccountRole.WRITABLE
  },
  { address: DELEGATION_PROGRAM_ID, role: AccountRole.READONLY },
  { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY },
  ...remainingAccounts,
];

const serializedInstructionData = Buffer.concat([
  Buffer.from(CounterInstruction.Delegate, "hex"),
  bumpBytes,
]);

Execute on Ephemeral Rollups

const accounts = [
  { address: userPubkey, role: AccountRole.WRITABLE_SIGNER},
  { address: counterPda, role: AccountRole.WRITABLE },
];

const serializedInstructionData = Buffer.concat([
  Buffer.from(CounterInstruction.IncreaseCounter, "hex"),
  bumpBytes,
  borsh.serialize(
    IncreaseCounterPayload.schema,
    new IncreaseCounterPayload(1)
  ),
]);

const increaseCounterIx: Instruction = {
  accounts,
  programAddress: PROGRAM_ID,
  data: serializedInstructionData,
};

const transactionMessage = pipe(
  createTransactionMessage({ version: 0 }),
  tx => setTransactionMessageFeePayer(userPubkey, tx),
  tx => appendTransactionMessageInstructions([increaseCounterIx], tx)
);

const txHash = await ephemeralConnection.sendAndConfirmTransaction(
  transactionMessage,
  [userKeypair],
  { commitment: "confirmed", skipPreflight: true }
);

Performance Benefits

Smaller Program Size

No Borsh dependency reduces compiled program size

Lower Compute Units

Direct memory access is more efficient than serialization

Minimal Dependencies

Pinocchio is lightweight with fewer external crates

Fixed Allocations

No Vec types means predictable memory usage
Pinocchio requires manual memory management and is more error-prone than frameworks like Anchor. Use it when you need maximum performance and minimal size.

Account Structure

The Counter account is simple:
  • Size: 8 bytes
  • Layout: Single u64 count value
  • Serialization: Direct memory casting (no Borsh)

Source Code

View the complete source code on GitHub: pinocchio-counter on GitHub

Build docs developers (and LLMs) love