Create your own
Lesson illustration

Transfer SOL Using CPI

Welcome back. In the previous lesson, you used a CPI to ask the System Program to create an account: the payer and new account signed the outer transaction, then your program invoked create_account and initialized its own data.

This lesson applies the same nested-invocation model to the other everyday System Program operation: moving native SOL. By the end, you will be able to implement a native Rust handler that receives a transfer request and performs a System Program transfer CPI from a signing sender to a writable recipient. This is deliberately the non-PDA case; the sender’s wallet signature is supplied by the outer transaction.


Why route a SOL transfer through your program?

A user can transfer SOL directly from a client using the System Program. If the action is merely “send this amount to that address,” that direct transaction is usually the better design: it is simpler and consumes less compute.

A CPI becomes useful when the SOL movement is one component of your application’s rule set. For example, your program might:

  • accept a payment only while an offer is active;
  • send a fixed protocol fee alongside a state update;
  • distribute funds after checking conditions stored in program accounts;
  • release funds from a program-controlled treasury later in the course.

For now, imagine a minimal program instruction called SolTransfer { amount }. The client invokes your program, and your program delegates the actual balance movement to the System Program.

The important boundary is authority: your program is not creating a signature for the user. The sender must already have signed the outer transaction. During the CPI, the runtime propagates that signer privilege to the System Program.

The transfer amount is expressed in lamports:

Use u64 for lamport amounts on-chain. It is the native representation used by Solana program interfaces and avoids floating-point precision problems.

Rust Solana Tutorial #5 - Transferring SOL

Watch “Rust Solana Tutorial #5 – Transferring SOL” by Coding & Crypto for a compact native-Rust walkthrough. The first segment establishes why the System Program handles SOL transfers; the second shows the core invoke implementation you are about to analyze more carefully.

Watch the setup to connect SOL, lamports, and the built-in System Program. Then watch the Rust CPI. Focus on the three inputs to the operation: sender account, recipient account, and a deserialized u64 amount.


The account contract

Your custom instruction needs three accounts, in this order:

PositionAccountRequired outer-transaction privilegesPurpose
1senderWritable, signerThe System Program debits lamports from this account.
2recipientWritableThe System Program credits lamports to this account. It does not sign.
3system_programRead-only, executableThe built-in program that executes the transfer instruction.

The sender is writable because its lamport balance changes, and it is a signer because spending funds needs authorization. The recipient is writable for the corresponding balance increase, but does not need to authorize receiving funds.

For the basic pattern, treat the sender as a normal System Program-owned wallet account. The recipient may be another user’s wallet. A real application can impose stronger business rules, such as checking that the recipient is the address recorded in a configuration account rather than allowing an arbitrary caller-selected address.

The System Program account is passed to the outer instruction even though the transfer itself logically concerns only a source, a destination, and an amount. It identifies the executable program your CPI is authorized to call.

A Solana explorer’s transaction-details view: an outer program instruction contains a nested System Program instruction, labeled 1.1, which transfers 0.01 SOL between two accounts.

That nested entry is the practical trace to expect. In transaction logs, your program is invoked first, then the System Program executes within its call stack, and then control returns to your program.


Read the canonical native pattern

The official documentation presents both Anchor and native Rust versions of this exact operation. The native version closely matches the handler you will build below.

CPIs without PDA Signers

Read the official Solana documentation’s native Rust example. It shows a Borsh-decoded SolTransfer instruction, account extraction, construction of system_instruction::transfer, and the invoke call.

Scroll to the Rust subsection, not the Anchor example above it. Read the native listing from its imports through the end of process_instruction. Track how the amount moves from instruction data into the System Program instruction, and note that only sender_info is checked as a signer.

The documentation’s example intentionally stays compact. In a program you maintain, make the account assumptions visible through validation. That makes malformed client calls fail predictably and makes security review substantially easier.


Implementing the transfer CPI in native Rust

Assume the entrypoint has already deserialized instruction data, as in the previous lesson:

match instruction {
    ProgramInstruction::SolTransfer { amount } => {
        // Transfer handler goes here.
    }
}

Inside that match arm, implement the following:

use solana_program::{
    account_info::AccountInfo,
    program::invoke,
    program_error::ProgramError,
    system_instruction,
    system_program,
};

// `accounts` is the AccountInfo slice supplied to your program.
// `amount` is the u64 decoded from ProgramInstruction::SolTransfer.

let [sender_info, recipient_info, system_program_info] = accounts else {
    return Err(ProgramError::NotEnoughAccountKeys);
};

// The outer transaction must authorize the debit.
if !sender_info.is_signer {
    return Err(ProgramError::MissingRequiredSignature);
}

// Both balances will change.
if !sender_info.is_writable || !recipient_info.is_writable {
    return Err(ProgramError::InvalidArgument);
}

// This basic version spends from an ordinary System Program account.
if sender_info.owner != &system_program::id() {
    return Err(ProgramError::IllegalOwner);
}

// Do not accept an arbitrary executable account in place of the System Program.
if system_program_info.key != &system_program::id() {
    return Err(ProgramError::IncorrectProgramId);
}

let transfer_ix = system_instruction::transfer(
    sender_info.key,
    recipient_info.key,
    amount,
);

invoke(
    &transfer_ix,
    &[
        sender_info.clone(),
        recipient_info.clone(),
        system_program_info.clone(),
    ],
)?;

Ok(())

Account parsing is an interface contract

This line requires exactly three accounts:

let [sender_info, recipient_info, system_program_info] = accounts else {
    return Err(ProgramError::NotEnoughAccountKeys);
};

The client must supply its outer instruction’s account metas in this same order. Unlike a TypeScript object keyed by field names, native program account input is fundamentally positional.

Using array destructuring is useful in a minimal handler because it rejects missing or unexpected accounts. In a larger instruction, next_account_info is also common, particularly when the account list is longer or conditionally structured.

Validate privileges before the CPI

The check:

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

does not mean your program has cryptographically verified a new signature. Rather, it reads a privilege that the Solana runtime established when it verified the outer transaction.

The System Program will enforce its own rules as well. Your explicit checks still matter because they state your program’s intended contract and return an error at the point where the bad input enters your logic.

Two details are easy to confuse:

  • Signer authorizes a particular transaction.
  • Owner determines which program may modify an account’s data and, under Solana’s rules, debit its lamports.

For this wallet-funded pattern, the sender is System Program-owned and signs. In the next lesson, a PDA will become a program-controlled sender without holding a private key; that requires a different CPI mechanism.

Constructing the inner instruction

let transfer_ix = system_instruction::transfer(
    sender_info.key,
    recipient_info.key,
    amount,
);

This creates an Instruction value. It specifies:

  1. the System Program as the program to call;
  2. the sender and recipient public keys;
  3. the requested lamport amount encoded as instruction data.

At this point, no SOL has moved. As with account creation, constructing an instruction is only preparing a request.

Invoking the System Program

invoke(
    &transfer_ix,
    &[
        sender_info.clone(),
        recipient_info.clone(),
        system_program_info.clone(),
    ],
)?;

invoke performs the actual CPI. The AccountInfo handles supplied in the slice give the runtime the concrete accounts needed to execute the inner instruction.

clone() here does not copy account data or duplicate an on-chain account. It copies the Rust AccountInfo handle, allowing it to be forwarded into the CPI.

If the System Program accepts the transfer, it debits amount lamports from the sender and credits the recipient by the same amount. If the sender lacks sufficient funds, has not signed, or an account is not writable as required, the instruction fails. Because the CPI is inside the enclosing transaction, program state changes from this instruction are rolled back on failure.


What the client has to provide

Although the System Program executes the balance change, the client is still constructing an instruction to your program, not a standalone System Program transfer.

Conceptually, the outer instruction needs these account metas:

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

The client must also serialize instruction data in the format your program expects. If your enum is Borsh-serialized as:

enum ProgramInstruction {
    SolTransfer { amount: u64 },
}

then the client must encode both the enum variant and the u64 amount according to that same layout. This is why on-chain and client-side instruction definitions must evolve together.

At runtime, the execution sequence is:

  1. The transaction invokes your program with the three accounts and encoded amount.
  2. Your program validates its account contract and builds the System Program transfer instruction.
  3. invoke executes the nested System Program instruction using the sender’s already-provided signer privilege.
  4. If the CPI succeeds, your handler can continue; if it fails, ? returns the error and the instruction fails.

A useful debugging routine is to compare balances before and after a test, then inspect transaction logs for the nested System Program invocation. The transaction-details image above represents the explorer view you are looking for.


When this pattern is appropriate—and when it is not

This basic handler authorizes a signed user to transfer their own SOL to a supplied recipient. That means it is a relay around behavior the user could have performed directly.

Do not use such a relay merely to hide a direct transfer behind a custom instruction. A program-mediated transfer should enforce a meaningful rule or accompany a meaningful state transition. For example, a marketplace purchase might verify an order account, update its status, and pay a seller only if the order is still open.

Also avoid assuming that “the sender signs” solves every authorization issue. It prevents your program from spending an unsigned user’s wallet, but it does not ensure that the recipient is correct for your application. If a protocol payment must go to a configured treasury, validate the treasury address or derive it deterministically rather than accepting any recipient the caller provides.

In Anchor, this same CPI is expressed more declaratively. Signer<'info> enforces the sender signature, #[account(mut)] expresses writable privileges, and Program<'info, System> verifies the System Program account. A CpiContext plus Anchor’s system_program::transfer helper then performs the same underlying invocation. The runtime model remains exactly the one you used in native Rust.


Key takeaways

  • A SOL transfer CPI asks the System Program to move lamports during your program’s execution.
  • In the basic pattern, the sender is writable, System Program-owned, and a signer of the outer transaction; the recipient is writable but need not sign.
  • system_instruction::transfer constructs the inner instruction, while invoke executes it.
  • CPI signer privilege is propagated from the outer transaction. Your program cannot make a regular unsigned wallet sign.
  • A CPI-based transfer is valuable when it is part of application logic, not merely a more complicated substitute for a direct wallet transfer.
  • Validate not only signer and mutability privileges but also application-level authorization, especially the intended recipient.

Next, you will examine Program Derived Addresses (PDAs): deterministic addresses a program can control without a private key. That is the foundation for program treasuries and transfers in which the sender is not a user wallet.

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

Sign up