How to CPI with a PDA Signer in a Solana program

This guide uses the Anchor framework to demonstrate how to transfer SOL using a Cross-Program Invocation (CPI) where the sender is a PDA that the program must sign for.

A typical use case for this scenario is a program that manages token accounts on behalf of users. For instance, consider a scenario where a DeFi protocol pools user funds into a single account. The protocol needs to include security checks to automatically handle withdrawal requests. In such cases, the control over these pooled funds is not with a single user but rather with the program itself. This requires the use of PDAs as the owner of the protocol's token accounts to programmatically sign for withdrawals.

Included below are two different, but functionally equivalent implementations that you may come across when reading or writing Solana programs. Here is the final reference program on Solana Playground.

Starter Code #

Here is a starter program on Solana Playground. The lib.rs file includes the following program with a single sol_transfer instruction.

lib.rs
use anchor_lang::prelude::*;
use anchor_lang::system_program::{transfer, Transfer};
 
declare_id!("3455LkCS85a4aYmSeNbRrJsduNQfYRY82A7eCD3yQfyR");
 
#[program]
pub mod cpi {
    use super::*;
 
    pub fn sol_transfer(ctx: Context<SolTransfer>, amount: u64) -> Result<()> {
        let from_pubkey = ctx.accounts.pda_account.to_account_info();
        let to_pubkey = ctx.accounts.recipient.to_account_info();
        let program_id = ctx.accounts.system_program.to_account_info();
 
        let seed = to_pubkey.key();
        let bump_seed = ctx.bumps.pda_account;
        let signer_seeds: &[&[&[u8]]] = &[&[b"pda", seed.as_ref(), &[bump_seed]]];
 
        let cpi_context = CpiContext::new(
            program_id,
            Transfer {
                from: from_pubkey,
                to: to_pubkey,
            },
        )
        .with_signer(signer_seeds);
 
        transfer(cpi_context, amount)?;
        Ok(())
    }
}
 
#[derive(Accounts)]
pub struct SolTransfer<'info> {
    #[account(
        mut,
        seeds = [b"pda", recipient.key().as_ref()],
        bump,
    )]
    pda_account: SystemAccount<'info>,
    #[account(mut)]
    recipient: SystemAccount<'info>,
    system_program: Program<'info, System>,
}

The cpi.test.ts file demonstrates how to invoke the custom sol_transfer instruction and logs a link to the transaction details on SolanaFM.

It shows how to derive the PDA using the seeds specified in the program:

const [PDA] = PublicKey.findProgramAddressSync(
  [Buffer.from("pda"), wallet.publicKey.toBuffer()],
  program.programId,
);

The first step in this example is to fund the PDA account with a basic SOL transfer from the Playground wallet.

cpi.test.ts
it("Fund PDA with SOL", async () => {
  const transferInstruction = SystemProgram.transfer({
    fromPubkey: wallet.publicKey,
    toPubkey: PDA,
    lamports: transferAmount,
  });
 
  const transaction = new Transaction().add(transferInstruction);
 
  const transactionSignature = await sendAndConfirmTransaction(
    connection,
    transaction,
    [wallet.payer], // signer
  );
 
  console.log(
    `\nTransaction Signature:` +
      `https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,
  );
});

Once the PDA is funded with SOL, invoke the sol_transfer instruction. This instruction transfers SOL from the PDA back to the wallet account via a CPI to the System Program, which is "signed" for by the custom program.

it("SOL Transfer with PDA signer", async () => {
  const transactionSignature = await program.methods
    .solTransfer(new BN(transferAmount))
    .accounts({
      pdaAccount: PDA,
      recipient: wallet.publicKey,
    })
    .rpc();
 
  console.log(
    `\nTransaction Signature: https://solana.fm/tx/${transactionSignature}?cluster=devnet-solana`,
  );
});

The transaction details will show that the custom program was first invoked (instruction 1), which then invokes the System Program (instruction 1.1), resulting in a successful SOL transfer.

Transaction DetailsTransaction Details

You can build, deploy, and run the test to view the transaction details on the SolanaFM explorer.

How to CPI with signers using Anchor #

In the starter code, the SolTransfer struct specifies the accounts required by the transfer instruction.

#[derive(Accounts)]
pub struct SolTransfer<'info> {
    #[account(
        mut,
        seeds = [b"pda", recipient.key().as_ref()],
        bump,
    )]
    pda_account: SystemAccount<'info>,
    #[account(mut)]
    recipient: SystemAccount<'info>,
    system_program: Program<'info, System>,
}

The seeds to derive the address for the pda_account include the hardcoded string "pda" and the address of the recipient account. This means the address for the pda_account is unique for each recipient.

The Javascript equivalent to derive the PDA is included in the test file.

const [PDA] = PublicKey.findProgramAddressSync(
  [Buffer.from("pda"), wallet.publicKey.toBuffer()],
  program.programId,
);

Anchor CpiContext #

The sol_transfer instruction included in the starter code shows a typical approach for constructing CPIs using the Anchor framework.

This approach involves creating a CpiContext, which includes the program_id and accounts required for the instruction being called, followed by a helper function (transfer) to invoke a specific instruction.

pub fn sol_transfer(ctx: Context<SolTransfer>, amount: u64) -> Result<()> {
    let from_pubkey = ctx.accounts.pda_account.to_account_info();
    let to_pubkey = ctx.accounts.recipient.to_account_info();
    let program_id = ctx.accounts.system_program.to_account_info();
 
    let seed = to_pubkey.key();
    let bump_seed = ctx.bumps.pda_account;
    let signer_seeds: &[&[&[u8]]] = &[&[b"pda", seed.as_ref(), &[bump_seed]]];
 
    let cpi_context = CpiContext::new(
        program_id,
        Transfer {
            from: from_pubkey,
            to: to_pubkey,
        },
    )
    .with_signer(signer_seeds);
 
    transfer(cpi_context, amount)?;
    Ok(())
}

When signing with PDAs, the optional seeds and bump seed are included in the cpi_context as signer_seeds using with_signer().

let seed = to_pubkey.key();
let bump_seed = ctx.bumps.pda_account;
let signer_seeds: &[&[&[u8]]] = &[&[b"pda", seed.as_ref(), &[bump_seed]]];
 
let cpi_context = CpiContext::new(
    program_id,
    Transfer {
        from: from_pubkey,
        to: to_pubkey,
    },
)
.with_signer(signer_seeds);

The cpi_context and amount are then passed into the transfer function to execute the CPI.

transfer(cpi_context, amount)?;

When the CPI is processed, the Solana runtime will validate that the provided seeds and caller program ID derive a valid PDA. The PDA is then added as a signer on the invocation. This mechanism allows for programs to programmatically sign for PDAs that are derived from their program ID.

Invoke with Crate Helper #

Under the hood, the example above is a wrapper around the invoke_signed() function which uses system_instruction::transfer to build the instruction.

The example below demonstrates how to use the invoke_signed() function to make a CPI to the transfer instruction of the System Program using the system_instruction::transfer method and signed for by a PDA.

First, add these imports to the top of lib.rs:

use anchor_lang::solana_program::{program::invoke_signed, system_instruction};

Next, modify the sol_transfer instruction with the following:

pub fn sol_transfer(ctx: Context<SolTransfer>, amount: u64) -> Result<()> {
    let from_pubkey = ctx.accounts.pda_account.to_account_info();
    let to_pubkey = ctx.accounts.recipient.to_account_info();
    let program_id = ctx.accounts.system_program.to_account_info();
 
    let seed = to_pubkey.key();
    let bump_seed = ctx.bumps.pda_account;
    let signer_seeds: &[&[&[u8]]] = &[&[b"pda", seed.as_ref(), &[bump_seed]]];
 
    let instruction =
        &system_instruction::transfer(&from_pubkey.key(), &to_pubkey.key(), amount);
 
    invoke_signed(instruction, &[from_pubkey, to_pubkey, program_id], signer_seeds)?;
    Ok(())
}

This implementation is functionally equivalent to the previous example. The signer_seeds are passed into the invoke_signed function.