Create your own
Lesson illustration

Creating an Account with CPI

Welcome. This module begins the practical core of Solana composition: one program can call another program during the same transaction. You have already encountered the System Program as the owner of ordinary wallet accounts and as the protocol component that creates accounts. Here, you will make that relationship concrete by writing a Cross-Program Invocation (CPI) that asks the System Program to create a data account owned by your program.

By the end, you should be able to trace the complete operation: a client supplies the required accounts and signatures, your program constructs a System Program instruction, invoke executes it, and your program writes its initial state into the newly created account.


Why account creation is a CPI

A Solana program does not have a private storage area in the EVM-contract sense. Persistent state lives in separate data accounts, and each account has an owner: the program authorized to modify that account’s data.

Creating a new account entails several privileged state changes:

  1. Deduct lamports from a funding account.
  2. Allocate a fixed number of bytes for the new account’s data.
  3. Assign ownership of that data account to a program.

The System Program performs these changes. Your application program therefore cannot directly allocate account storage or take lamports from a user wallet; it makes a CPI to the System Program and requests its create_account instruction.

A client submits an instruction to an application program; during execution, that program synchronously invokes another on-chain program, such as the System Program, before completing its own remaining logic.

A CPI is not a second wallet transaction. It is a nested instruction executed within the original transaction. This has two important consequences:

  • Atomicity: if account creation or subsequent initialization fails, the transaction fails as a whole. The account is not left partially initialized.
  • Privilege propagation: the called program receives only the signer and writable privileges that the outer transaction supplied. Your program cannot make an unsigned user become a signer merely by invoking another program.

Watch the native Rust portion of Solana’s short explanation now.

Solana Bytes - Cross Program Invocation

In “Solana Bytes – Cross Program Invocation” from the Solana channel, the first segment establishes the mental model for a CPI, and the second maps it directly to System Program account creation.

Watch the CPI idea to distinguish a client instruction from an instruction created by a program at runtime. Then watch the native example. Focus on the two inputs to invoke: the instruction targeting the System Program and the forwarded account infos.


The account contract for create_account

Consider a minimal counter account. It needs only one u64 value, so its serialized data is 8 bytes. A client sends an InitializeCounter instruction to your program with three accounts in this exact order:

PositionAccountRequired propertiesRole
1counter_accountWritable, signerNew account receiving data and lamports
2payer_accountWritable, signerFunds the account’s rent-exempt balance
3system_programExecutableThe built-in program that creates the account

The new account must sign because, in this version, it is represented by a newly generated keypair. The client holds that keypair’s secret key and includes it among the transaction signers. The payer also signs because lamports are deducted from it.

Later in this module, a Program Derived Address (PDA) will change this pattern. A PDA has no private key, so it cannot sign like a normal keypair; your program will instead prove authority with invoke_signed. For now, keep the simpler model clear: a client-created keypair supplies the new account address and signature.

The System Program instruction also specifies four pieces of data:

  • the payer public key;
  • the new account public key;
  • the lamports to deposit;
  • the allocated data length and the program ID that will own the new account.

The ownership assignment is essential. After creation, the System Program no longer owns the counter’s data: your program ID does. That gives your program authority to serialize and update its state.


Reading the official native implementation

The Solana documentation presents this exact pattern in the context of a native Rust counter program.

Rust Program Structure

Read the “Implement initialize handler” example in Solana’s Rust Program Structure guide. It puts account creation, rent calculation, CPI execution, and Borsh initialization in one compact handler.

In the “Implement initialize handler” section, begin with the explanation of ownership, then read the complete process_initialize_counter listing immediately below it. Track the three accounts as they are extracted, then identify where the generated System Program instruction is passed to invoke. Finally, note that serialization occurs only after the CPI succeeds.

The documentation code is intentionally compact. Let’s unpack it and add a few basic guard checks that make the assumptions explicit.


Implementing the CPI in native Rust

The following handler assumes you already have a Borsh-serializable state type such as:

pub struct CounterAccount {
    pub count: u64,
}

The essential handler is:

use borsh::BorshSerialize;
use solana_program::{
    account_info::{next_account_info, AccountInfo},
    program::invoke,
    program_error::ProgramError,
    pubkey::Pubkey,
    rent::Rent,
    system_instruction,
    system_program,
    entrypoint::ProgramResult,
};

fn process_initialize_counter(
    program_id: &Pubkey,
    accounts: &[AccountInfo],
    initial_value: u64,
) -> ProgramResult {
    let account_info_iter = &mut accounts.iter();

    let counter_account = next_account_info(account_info_iter)?;
    let payer_account = next_account_info(account_info_iter)?;
    let system_program_account = next_account_info(account_info_iter)?;

    if !payer_account.is_signer || !counter_account.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    if !payer_account.is_writable || !counter_account.is_writable {
        return Err(ProgramError::InvalidAccountData);
    }

    if system_program_account.key != &system_program::id() {
        return Err(ProgramError::IncorrectProgramId);
    }

    let account_space: usize = 8;
    let required_lamports = Rent::get()?.minimum_balance(account_space);

    let create_account_instruction = system_instruction::create_account(
        payer_account.key,
        counter_account.key,
        required_lamports,
        account_space as u64,
        program_id,
    );

    invoke(
        &create_account_instruction,
        &[
            payer_account.clone(),
            counter_account.clone(),
            system_program_account.clone(),
        ],
    )?;

    let counter_data = CounterAccount {
        count: initial_value,
    };

    let mut data_buffer = counter_account.data.borrow_mut();
    counter_data.serialize(&mut &mut data_buffer[..])?;

    Ok(())
}

1. Reading AccountInfo values

The accounts slice is supplied by the transaction instruction. It is not a map keyed by account type, so the order your handler reads must match the order the client used when building the outer instruction.

let counter_account = next_account_info(account_info_iter)?;
let payer_account = next_account_info(account_info_iter)?;
let system_program_account = next_account_info(account_info_iter)?;

next_account_info returns an error rather than panicking when a required account is missing. This is a normal example of Rust’s ? operator propagating a recoverable program error.

2. Checking the privileges you need

The System Program will reject an account creation request if the needed privileges are absent, but explicitly checking them documents your program’s contract and lets you fail closer to the source of the problem.

  • The payer needs to be writable because its lamport balance decreases.
  • The new account needs to be writable because creation changes its lamports, owner, and data allocation.
  • Both need to be signers in this keypair-based creation pattern.
  • The supplied program account must actually be the known System Program, rather than an arbitrary executable account substituted by a caller.

Notice the difference between “the counter account signs” and “your program owns the counter account.” Signing authorizes the initial creation; ownership determines who can subsequently modify its data.

3. Determining account size and funding

let account_space: usize = 8;
let required_lamports = Rent::get()?.minimum_balance(account_space);

Account data capacity is fixed at creation. If your state is a single Borsh-encoded u64, 8 bytes are enough. If you later add fields, existing accounts do not automatically grow; state sizing and migration need deliberate design.

Rent::get() retrieves the cluster’s Rent sysvar, and minimum_balance calculates the lamports needed for that amount of space. Those lamports are transferred from the payer into the new account as part of the creation instruction.

For a more realistic state struct, calculate its exact serialized maximum size rather than guessing. Under-allocation produces serialization failures; excessive allocation locks more lamports than necessary.

4. Constructing an instruction is not executing it

let create_account_instruction = system_instruction::create_account(
    payer_account.key,
    counter_account.key,
    required_lamports,
    account_space as u64,
    program_id,
);

This helper only builds an Instruction value. In particular, it encodes the requested operation and identifies the System Program as its target. No account exists yet.

The last parameter, program_id, becomes the new account’s owner. It should be the ID of the program currently executing, not the System Program ID. Otherwise, your program would be unable to write its counter state after creation.

5. invoke performs the nested call

invoke(
    &create_account_instruction,
    &[
        payer_account.clone(),
        counter_account.clone(),
        system_program_account.clone(),
    ],
)?;

invoke hands the constructed instruction to the runtime. The second argument forwards the actual AccountInfo objects needed by the System Program.

The clone() calls do not duplicate on-chain accounts or their data. AccountInfo is a lightweight Rust handle to the runtime-provided account. Cloning it lets the handle be supplied to the CPI while remaining available afterward in the handler.

After invoke returns successfully:

  • the payer has funded the new account;
  • the counter account has an 8-byte data buffer;
  • its owner is your program;
  • your program can write its initialized counter state.

6. Initialize while the account is known to be fresh

let mut data_buffer = counter_account.data.borrow_mut();
counter_data.serialize(&mut &mut data_buffer[..])?;

The mutable borrow accesses the account’s fixed-size data buffer. Borsh writes the u64 representation of initial_value into it.

Initialization immediately after creation is an effective baseline pattern: the account is created and populated in one atomic instruction. Still, production programs need further validation rules to prevent an attacker from passing an already initialized or wrongly typed account. You will systematize those constraints in the program security module.


What the client must still do

The client does not construct a separate System Program create_account instruction. It constructs one instruction targeting your program and supplies all three required accounts. Conceptually, its account metas look like this:

vec![
    AccountMeta::new(counter_keypair.pubkey(), true),
    AccountMeta::new(payer.pubkey(), true),
    AccountMeta::new_readonly(system_program::id(), false),
]

The outer transaction must be signed by both payer and counter_keypair. When your program performs the CPI, the runtime forwards those existing signer privileges to the System Program. It does not create fresh signatures during execution.

A useful implementation checkpoint is to inspect transaction logs after running your instruction. You should see your program invoked, then the System Program invoked as a nested call, and finally your program either complete initialization or fail. This is the concrete trace of the CPI model.


Native CPI versus Anchor init

In Anchor, account creation commonly appears as a declarative account constraint:

#[account(
    init,
    payer = payer,
    space = 8 + Counter::INIT_SPACE,
)]
pub counter: Account<'info, Counter>,

Anchor performs the underlying System Program work before your instruction handler runs. payer identifies the funding signer, space reserves data capacity, and init requests account creation. Anchor accounts also include an 8-byte discriminator, which is why the space calculation differs from the native 8-byte counter example.

The abstraction is convenient, but the mechanism is the same: account creation requires a System Program CPI. Knowing the native form helps when debugging account constraints, interpreting logs, auditing permissions, or writing native programs without Anchor.


Key takeaways

  • A CPI is a synchronous nested program invocation within one atomic Solana transaction.
  • The System Program creates accounts because it handles funding, allocation, and initial ownership assignment.
  • A native program creates an account by constructing system_instruction::create_account and passing it with the required AccountInfo values to invoke.
  • In the keypair-based pattern, both the payer and the new account sign the outer transaction.
  • Set the new account’s owner to your program ID, then serialize initial state only after the creation CPI succeeds.
  • Anchor’s init constraint automates this same System Program interaction.

Next, you will use the same CPI structure for a simpler but equally common operation: transferring SOL through the System Program.

Can't find a good explanation? Sign up and we'll make it for you

Sign up