This guide will walk you through the process of writing a simple native Rust program (without Anchor dependencies) that increments a counter. You’ll learn how to deploy this program on Solana and interact with it using a Typescript test script.

Software Packages

This program is developed and tested with the following software packages. Other sofware may also be compatible.

SoftwareVersionInstallation Guide
Solana2.0.21Install Solana
Rust1.82.0Install Rust

Quick Access

If you prefer to dive straight into the code:

Core Functionality

The program implements two main instructions:

  1. InitializeCounter: Initialize and sets the counter to 0 (called on Base Layer)
  2. IncreaseCounter: Increments the initialized counter by X amount (called on Base Layer or ER)

The program implements three instructions for delegating and undelegating the counter:

  1. Delegate: Delegates counter from Base Layer to ER (called on Base Layer)
  2. CommitAndUndelegate: Schedules sync of counter from ER to Base Layer, and undelegates counter on ER (called on ER)
  3. Commit: Schedules sync of counter from ER to Base Layer (called on ER)
  4. Undelegate: Undelegates counter on the Base Layer (called on Base Layer through validator CPI)

Here’s the core structure of our program:

pub enum ProgramInstruction {
    InitializeCounter,
    IncreaseCounter {
        increase_by: u64
    },
    Delegate,
    CommitAndUndelegate,
    Commit,
    Undelegate {
        pda_seeds: Vec<Vec<u8>>
    }
}

#[derive(BorshDeserialize)]
struct IncreaseCounterPayload {
    increase_by: u64,
}

impl ProgramInstruction {
    pub fn unpack(input: &[u8]) -> Result<Self, ProgramError> {
        // Ensure the input has at least 8 bytes for the variant
        if input.len() < 8 {
            return Err(ProgramError::InvalidInstructionData);
        }

        // Extract the first 8 bytes as variant
        let (variant_bytes, rest) = input.split_at(8);
        let mut variant = [0u8; 8];
        variant.copy_from_slice(variant_bytes);

        Ok(match variant {
            [0, 0, 0, 0, 0, 0, 0, 0] => Self::InitializeCounter,
            [1, 0, 0, 0, 0, 0, 0, 0] => {
                let payload = IncreaseCounterPayload::try_from_slice(rest)?;
                Self::IncreaseCounter {
                    increase_by: payload.increase_by,
                }
            },
            [2, 0, 0, 0, 0, 0, 0, 0] => Self::Delegate,
            [3, 0, 0, 0, 0, 0, 0, 0] => Self::CommitAndUndelegate,
            [4, 0, 0, 0, 0, 0, 0, 0] => Self::Commit,
            [196, 28, 41, 206, 48, 37, 51, 167] => {
                let pda_seeds: Vec<Vec<u8>> = Vec::<Vec<u8>>::try_from_slice(rest)?;
                Self::Undelegate {
                    pda_seeds
                }
            }
            _ => return Err(ProgramError::InvalidInstructionData),
        })
    }
}

Your “Undelegate” instruction must have the exact discriminator. It is never called by you, instead the validator on the Base Layer will callback with a CPI into your program after undelegating your account on ER.

Delegating the Counter PDA

In order to delegate the counter PDA, and make it writable in an Ephemeral Rollup session, we need to add an instruction which internally calls the delegate_account function. delegate_account will CPI to the delegation program, which upon validation will gain ownership of the account. After this step, an ephemeral validator can start processing transactions on the counter PDA and propose state diff trough the delegation program.

Transaction (Base Layer): Delegate

Inspect transactions details on Solana Explorer
use solana_program::{
    account_info::{next_account_info, AccountInfo},
    pubkey::Pubkey,
    entrypoint::ProgramResult,
    program_error::ProgramError,
};
use ephemeral_rollups_sdk::cpi::{delegate_account, DelegateAccounts, DelegateConfig};

// For Base Layer only
pub fn process_delegate(
\_program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {

    // Get accounts
    let account_info_iter = &mut accounts.iter();
    let initializer = next_account_info(account_info_iter)?;
    let system_program = next_account_info(account_info_iter)?;
    let pda_to_delegate = next_account_info(account_info_iter)?;
    let owner_program = next_account_info(account_info_iter)?;
    let delegation_buffer = next_account_info(account_info_iter)?;
    let delegation_record = next_account_info(account_info_iter)?;
    let delegation_metadata = next_account_info(account_info_iter)?;
    let delegation_program = next_account_info(account_info_iter)?;

    // Prepare counter pda seeds
    let seed_1 = b"counter_account";
    let seed_2 = initializer.key.as_ref();
    let pda_seeds: &[&[u8]] = &[seed_1, seed_2];

    let delegate_accounts = DelegateAccounts {
        payer: initializer,
        pda: pda_to_delegate,
        owner_program,
        buffer: delegation_buffer,
        delegation_record,
        delegation_metadata,
        delegation_program,
        system_program,
    };

    let delegate_config = DelegateConfig {
        commit_frequency_ms: 30_000,
        validator: None,
    };

    delegate_account(delegate_accounts, pda_seeds, delegate_config)?;

    Ok(())

}

Committing while the PDA is delegated

The ephemeral runtime allow to commit the state of the PDA while it is delegated. This is done by calling the commit_accounts function.

use solana_program::{
    account_info::{next_account_info, AccountInfo},
    pubkey::Pubkey,
    entrypoint::ProgramResult,
    program_error::ProgramError,
};
use ephemeral_rollups_sdk::ephem::{commit_accounts};

// For ER only
pub fn process_commit(
    _program_id: &Pubkey,
    accounts: &[AccountInfo],
) -> ProgramResult {
    // Get accounts
    let account_info_iter = &mut accounts.iter();
    let initializer = next_account_info(account_info_iter)?;
    let counter_account = next_account_info(account_info_iter)?;
    let magic_program = next_account_info(account_info_iter)?;
    let magic_context = next_account_info(account_info_iter)?;

    // Signer should be the same as the initializer
    if !initializer.is_signer {
        msg!("Initializer {} should be the signer", initializer.key);
        return Err(ProgramError::MissingRequiredSignature);
    }

    commit_accounts(
        initializer,
        vec![counter_account],
        magic_context,
        magic_program,
    )?;

    Ok(())
}

Undelegating the PDA

Undelegating the PDA is done by calling the commit_and_undelegate_accounts as part of some instruction. Undelegation commit the latest state and give back the ownership of the PDA to the owner program. After undelegating and finalizing the state, the validator will create a callback CPI into “undelegate” on the Base Layer.

use solana_program::{
    account_info::{next_account_info, AccountInfo},
    pubkey::Pubkey,
    entrypoint::ProgramResult,
    program_error::ProgramError,
};
use ephemeral_rollups_sdk::cpi::{undelegate_account};
use ephemeral_rollups_sdk::ephem::{commit_and_undelegate_accounts, commit_accounts};

// For ER only
pub fn process_commit_and_undelegate(
\_program_id: &Pubkey,
accounts: &[AccountInfo],
) -> ProgramResult {
// Get accounts
let account_info_iter = &mut accounts.iter();
let initializer = next_account_info(account_info_iter)?;
let counter_account = next_account_info(account_info_iter)?;
let magic_program = next_account_info(account_info_iter)?;
let magic_context = next_account_info(account_info_iter)?;

    // Signer should be the same as the initializer
    if !initializer.is_signer {
        msg!("Initializer {} should be the signer", initializer.key);
        return Err(ProgramError::MissingRequiredSignature);
    }

    // Commit and undelegate counter_account on ER
    commit_and_undelegate_accounts(
        initializer,
        vec![counter_account],
        magic_context,
        magic_program,
    )?;

    Ok(())

}

// For Base Layer CPI callback
pub fn process_undelegate(
program_id: &Pubkey,
accounts: &[AccountInfo],
pda_seeds: Vec<Vec<u8>>
) -> ProgramResult {
// Get accounts
let account_info_iter = &mut accounts.iter();
let delegated_pda = next_account_info(account_info_iter)?;
let delegation_buffer = next_account_info(account_info_iter)?;
let initializer = next_account_info(account_info_iter)?;
let system_program = next_account_info(account_info_iter)?;

    // CPI on Solana
    undelegate_account(
        delegated_pda,
        program_id,
        delegation_buffer,
        initializer,
        system_program,
        pda_seeds,
    )?;

    Ok(())

}

Testing the program

Build valid transactions that calls your program instructions for delegation and undelegation. The complete test for this project can be found in the Typescript Test Script.

Test delegation transaction

Create a instruction with the right order and attributes of accounts, and the instruction discriminator for delegation of your program. Send the transaction with the instruction to Base Layer (Solana) network.

Transaction (Base Layer): Delegate

Inspect transactions details on Solana Explorer

import * as web3 from "@solana/web3.js";

// Build delegation transaction
const tx = new web3.Transaction();
const keys = [
  // Initializer
  {
    pubkey: userKeypair.publicKey,
    isSigner: true,
    isWritable: true,
  },
  // System Program
  {
    pubkey: web3.SystemProgram.programId,
    isSigner: false,
    isWritable: false,
  },
  // Counter Account
  {
    pubkey: counterPda,
    isSigner: false,
    isWritable: true,
  },
  // Owner Program
  {
    pubkey: PROGRAM_ID,
    isSigner: false,
    isWritable: false,
  },
  // Delegation Buffer
  {
    pubkey: getDelegationBufferPda(counterPda, PROGRAM_ID),
    isSigner: false,
    isWritable: true,
  },
  // Delegation Record
  {
    pubkey: getDelegationRecordPda(counterPda),
    isSigner: false,
    isWritable: true,
  },
  // Delegation Metadata
  {
    pubkey: getDelegationMetadataPda(counterPda),
    isSigner: false,
    isWritable: true,
  },
  // Delegation Program
  {
    pubkey: DELEGATION_PROGRAM_ID,
    isSigner: false,
    isWritable: false,
  },
];
const serializedInstructionData = Buffer.from(
  CounterInstruction.Delegate,
  "hex"
);
const delegateIx = new web3.TransactionInstruction({
  keys: keys,
  programId: PROGRAM_ID,
  data: serializedInstructionData,
});
tx.add(delegateIx);

// Send and confirm transaction to Base Layer
const connection = new web3.Connection(rpcSolana);
const txHash = await web3.sendAndConfirmTransaction(
  connection,
  tx,
  [userKeypair],
  {
    skipPreflight: true,
    commitment: "confirmed",
  }
);

Test commit transaction

Create a instruction with the right order and attributes of accounts, and the instruction discriminator for commit of your program. Send the transaction with the instruction to ER network.

import * as web3 from "@solana/web3.js";

// 3: Commit
// Create, send and confirm transaction
const tx = new web3.Transaction();
const keys = [
  // Initializer
  {
    pubkey: userKeypair.publicKey,
    isSigner: true,
    isWritable: true,
  },
  // Counter Account
  {
    pubkey: counterPda,
    isSigner: false,
    isWritable: true,
  },
  // Magic Program
  {
    pubkey: MAGIC_PROGRAM_ID,
    isSigner: false,
    isWritable: false,
  },
  // Magic Context
  {
    pubkey: MAGIC_CONTEXT_ID,
    isSigner: false,
    isWritable: true,
  },
];
const serializedInstructionData = Buffer.from(CounterInstruction.Commit, "hex");
const commitIx = new web3.TransactionInstruction({
  keys: keys,
  programId: PROGRAM_ID,
  data: serializedInstructionData,
});
tx.add(commitIx);
const connection = new web3.Connection(rpcMagicblock);
const txHash = await web3.sendAndConfirmTransaction(
  connection,
  tx,
  [userKeypair],
  {
    skipPreflight: true,
    commitment: "confirmed",
  }
);

Test undelegation transaction

Create a instruction with the right order and attributes of accounts, and the instruction discriminator for undelegation of your program. Send the transaction with the instruction to ER network.

import * as web3 from "@solana/web3.js"
// Build commit and undelegation transaction
const tx = new web3.Transaction();
const keys = [
  // Initializer
  {
  pubkey: userKeypair.publicKey,
  isSigner: true,
  isWritable: true,
  },
  // Counter Account
  {
  pubkey: counterPda,
  isSigner: false,
  isWritable: true,
  },
  // Magic Program
  {
  pubkey: MAGIC_PROGRAM_ID,
  isSigner: false,
  isWritable: false,
  },
  // Magic Context
  {
  pubkey: MAGIC_CONTEXT_ID,
  isSigner: false,
  isWritable: true,
  },
];
const serializedInstructionData = Buffer.from(
  CounterInstruction.CommitAndUndelegate,
  "hex"
);
const undelegateIx = new web3.TransactionInstruction({
  keys: keys,
  programId: PROGRAM_ID,
  data: serializedInstructionData,
});
tx.add(undelegateIx);

// Send and confirm transaction to ER. Afterwards CPI callback will be triggered to "Undelegate" instruction of your program on the Base Layer.
const connection = new web3.Connection(rpcER);
const txHash = await web3.sendAndConfirmTransaction(
connection,
tx,
[userKeypair],
{
skipPreflight: true,
commitment: "confirmed",
}
)

Ephemeral Endpoint Configuration

To interact with the Ephemeral Rollup session, you need to configure the appropriate endpoint:

  • For devnet, use the following ephemeral endpoint:

    https://devnet.magicblock.app

  • For mainnet, please reach out to the MagicBlock team to receive the appropriate endpoint.

Make sure to update your client configuration to use the correct endpoint based on your development or production environment.

Currently the routing to different endpoints needs to be done manually. These public RPC endpoints are currently free and supported for development:

Solana Devnet: https://api.devnet.solana.com
ER Devnet: https://devnet.magicblock.app

A smart RPC router for automatic routing is under development.