How does Pump.fun create a liquidity pool on Raydium?

A coding tour explaining how Pump.fun creates a liquidity pool on Raydium in an atomic and automated way. This simplified example demonstrates how and why Pump stores liquidity and migrates it to Raydium when possible.

What you will learn

Solana Account Types

Introduction to the basic types of accounts in Solana and when to use each type in different circumstances.

Raydium Instructions

Learn how to integrate with the Raydium SDK and use instructions to create a liquidity pool.

CPI to create a liquidity pool

Learn how to store assets in a program and invoke CPI to create a liquidity pool on Raydium.

Introduction

Pump.fun, the largest meme coin launchpad on Solana, offers a powerful feature: when a token reaches a certain price threshold, it can be paired with SOL to create a Raydium pool. The process involves three simple steps:

  • Create a meme token on the Pump.fun program
  • Swap the meme token on the Pump.fun program
  • Once the meme token's price reaches a target value, migrate the SOL and tokens from Pump.fun to create a Raydium pool, enabling trading on Raydium

The success of Pump.fun has inspired many developers to create their own forks. Steps 1 and 2 are straightforward since they occur entirely within your forked program. However, step 3 presents a challenge: migrating your assets to Raydium. There are two main difficulties:

  • Building the correct Raydium instructions
  • Understanding Solana's account management system

This article will walk through these challenges step by step and demonstrate how to overcome them.

How does Solana manage accounts

First, you need to understand the basics of Solana accounts. You can find detailed information in the official documentation. The content below focuses on what's needed for this article. If you're already familiar with Solana accounts, feel free to skip to the next section.

Everything in Solana is an account. An account contains the following information:

account.rs Rust

pub struct Account {
    /// lamports in the account
    pub lamports: u64,
    /// data held in this account
    #[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
    pub data: Vec<u8>,
    /// the program that owns this account. If executable, the program that loads this account.
    pub owner: Pubkey,
    /// this account's data contains a loaded program (and is now read-only)
    pub executable: bool,
    /// the epoch at which this account will next owe rent
    pub rent_epoch: Epoch,
}
    

Explanation: This struct defines the basic structure of a Solana account. The lamports field stores the account's balance, data holds the account's state, owner specifies which program controls the account, executable indicates if it contains executable code, and rent_epoch tracks when rent is due.


Based on the executable field, we can divide Solana accounts into two categories:

  • Program account: An account with executable = true. This account contains executable code. Deployed smart contracts use this type.
  • Data account: An account with executable = false. This account doesn't contain executable code and is used to store data for a program.

The owner is the program that owns the account. Only owner program can modify the account's state. Based on the owner address, we can divide Solana accounts into four categories:

  • Program state account: An account whose owner is a user-deployed program. It contains information related to that program.
  • System account: An account whose owner is the System Program. This type of account can pay transaction fees or rent-fee.
  • Sysvar account: An account whose owner is the Sysvar Program. These are predefined addresses that provide access to cluster state data.
  • Other: An account whose owner is built-in programs other than System program or Sysvar program. We just mention it here for completeness, it's not relate to our topic.

Each account is identified by a unique 32-byte ID called an address, typically displayed as a base58-encoded string. Based on how addresses are generated, Solana accounts fall into two categories:

  • Public key address: The account address is a public key from an Ed25519 keypair. Whoever holds the corresponding private key can sign for this account.
  • Program derived address (PDA): The account address is derived deterministically using a program's address and one or more optional seeds. This account has no private key. The Solana runtime allows the program from which the PDA is derived to sign on its behalf.

Solana Account Classification Summary

ClassificationCategoryDescriptionKey Characteristic
By executableProgram AccountContains executable codeexecutable = true
Data AccountStores data for programsexecutable = false
By ownerProgram State AccountProgram-specific data storageowner = User Program
System AccountCan pay transaction feesowner = System Program
Sysvar AccountCluster state data accessowner = Sysvar Program
OtherOther built-in programsowner = Other Built-in
By AddressPublic Key AddressEd25519 keypair-basedHas private key
PDADeterministically derivedNo private key

This table summarizes the different ways to classify Solana accounts based on their characteristics.

Create an FPump program

Now let's address the main purpose of this article: creating a Solana program that can migrate assets and create a Raydium pool. This section includes code examples, iterative updates, and references to a GitHub repository demonstrating the final implementation.

This article assumes you're familiar with Anchor and how to build programs on Solana using Anchor. Let's prepare the prerequisites and initialize an Anchor program.

account.rs Rust
# Prerequisites 
#  Anchor v0.31.1
#  Node v0.22.15
#  Rustc ≥ 1.79.0
#. Solana 2.3.0
anchor init pumpfun-integrate-raydium

Explanation: This command initializes a new Anchor project named "pumpfun-integrate-raydium". Make sure you have the required versions installed: Anchor v0.31.1, Node v0.22.15, Rust ≥ 1.79.0, and Solana 2.3.0.


In our FPump (Forked Pump) program, we use simplified logic supporting two instructions:

  • create_token: Creates a new mint, mints 1,000,000 tokens, and transfers 10 SOL into the vault. These assets are used to create a Raydium pool instead of waiting for users to swap.
  • migrate: Creates a Raydium pool with 1,000,000 tokens and 9 SOL. The remaining 1 SOL is used to pay the creation fee.

Create a new token

We create a new mint token with 6 decimals, initialize a Vault PDA that stores 1,000,000 of the newly created tokens, and transfer 10 SOL into the vault.

Raydium requires wrapped SOL (wSOL) instead of native SOL when creating a liquidity pool. Therefore, in our FPump program, we receive native SOL and convert it to wSOL using the sync_native instruction.

Please check the branch mint-token for more details.

create_token.rs Rust
use anchor_lang::{prelude::*, solana_program::native_token::LAMPORTS_PER_SOL};
use anchor_spl::{
    associated_token::AssociatedToken,
    token::{self, Mint, SyncNative, Token, TokenAccount, sync_native},
};
use anchor_lang::system_program::{transfer, Transfer};


pub const NATIVE_MINT: Pubkey = pubkey!("So11111111111111111111111111111111111111112");

#[derive(Accounts)]
pub struct CreateToken<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,

    #[account(
        init,
        payer = signer,
        mint::decimals = 6,
        mint::authority = vault,
    )]
    pub mint: Box<Account<'info, Mint>>,

    #[account(address = NATIVE_MINT)]
    pub wsol_mint: Box<Account<'info, Mint>>,

    #[account(
        init,
        payer = signer,
        space = 8 + Vault::INIT_SPACE,
        seeds = [Vault::SEED_PREFIX.as_bytes(), mint.key().as_ref()],
        bump,
    )]
    pub vault: Account<'info, Vault>,

    #[account(
        init,
        payer = signer,
        associated_token::mint = mint,
        associated_token::authority = vault,
    )]
    pub vault_token_account: Account<'info, TokenAccount>,

    #[account(
        init_if_needed,
        payer = signer,
        associated_token::mint = wsol_mint,
        associated_token::authority = vault
    )]
    pub wsol_vault_token_account: Box<Account<'info, TokenAccount>>,

    pub token_program: Program<'info, Token>,
    pub associated_token_program: Program<'info, AssociatedToken>,
    pub system_program: Program<'info, System>,
}

#[account]
#[derive(InitSpace)]
pub struct Vault {}

impl Vault {
    pub const SEED_PREFIX: &'static str = "vault";
}

pub fn handler(ctx: Context<CreateToken>) -> Result<()> {
    let mint_key = ctx.accounts.mint.key();
    let vault_seeds: &[&[&[u8]]] = &[&[
        Vault::SEED_PREFIX.as_bytes(),
        mint_key.as_ref(),
        &[ctx.bumps.vault],
    ]];

    token::mint_to(
        CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            token::MintTo {
                mint: ctx.accounts.mint.to_account_info(),
                to: ctx.accounts.vault_token_account.to_account_info(),
                authority: ctx.accounts.vault.to_account_info(),
            },
            vault_seeds,
        ),
        1000_000_000_000,
    )?;

    transfer(
        CpiContext::new(
            ctx.accounts.system_program.to_account_info(),
            Transfer {
                from: ctx.accounts.signer.to_account_info(),
                to: ctx.accounts.wsol_vault_token_account.to_account_info(),
            },
        ),
        10 * LAMPORTS_PER_SOL,
    )?;

    sync_native(CpiContext::new(
        ctx.accounts.token_program.to_account_info(),
        SyncNative {
            account: ctx.accounts.wsol_vault_token_account.to_account_info(),
        },
    ))?;

    Ok(())
}

Explanation: This code creates a new token mint and initializes a vault to store liquidity. The handler mints 1,000,000 tokens to the vault, transfers 10 SOL (converted to wSOL), and stores both assets in preparation for creating a Raydium pool. The vault uses a PDA (Program Derived Address) for secure asset management.

Create a Raydium pool

So far so good. Now let's create a Raydium pool. There are several Raydium programs available for creating pools, each corresponding to specific pool logic. In this article, we use the latest version of CPMM, which is optimized for pools that pair SOL with a token.

First, you need to install CPMM SDK. Add this dependency to your Cargo.toml, then rebuild your program.

Cargo.toml Toml
[dependencies]
raydium-cp-swap = { git = "https://github.com/raydium-io/raydium-cp-swap", features = [
    "no-entrypoint",
    "cpi",
] }

Explanation: This dependency adds the Raydium CPMM (Constant Product Market Maker) swap program to your project. The "no-entrypoint" and "cpi" features enable Cross-Program Invocation (CPI) from your program to Raydium's pool creation logic.


Then, create a migrate instruction in FPump that calls the initialize instruction to create a new pool in the CPMM program. Please check the branch migrate for more details
migrate.rs Rust
use anchor_lang::{prelude::*, solana_program::native_token::LAMPORTS_PER_SOL};

use anchor_spl::{
    associated_token::AssociatedToken,
    token::Token,
    token_interface::{Mint, TokenAccount, TokenInterface},
};
use raydium_cp_swap::{
    cpi,
    states::{OBSERVATION_SEED, POOL_LP_MINT_SEED, POOL_SEED, POOL_VAULT_SEED},
    AUTH_SEED,
};

use crate::Vault;

pub const NATIVE_MINT: Pubkey = pubkey!("So11111111111111111111111111111111111111112");

#[derive(Accounts)]
pub struct MigrateToken<'info> {
    #[account(mut)]
    pub signer: Signer<'info>,

    #[account()]
    pub mint: Box<InterfaceAccount<'info, Mint>>,

    #[account(address = NATIVE_MINT)]
    pub wsol_mint: Box<InterfaceAccount<'info, Mint>>,

    /// Token_0 mint, the key must smaller then token_1 mint.
    #[account(
        constraint = token_0_mint.key() < token_1_mint.key(),
        mint::token_program = token_0_program
    )]
    pub token_0_mint: Box<InterfaceAccount<'info, Mint>>,

    /// Token_1 mint, the key must grater then token_0 mint.
    #[account(mint::token_program = token_1_program)]
    pub token_1_mint: Box<InterfaceAccount<'info, Mint>>,

    /// CHECK: pool lp mint, init by cp-swap
    #[account(
        mut,
        seeds = [
            POOL_LP_MINT_SEED.as_bytes(),
            pool_state.key().as_ref(),
        ],
        seeds::program = cp_swap_program,
        bump,
    )]
    pub lp_mint: UncheckedAccount<'info>,

    #[account(
        mut,
        seeds = [Vault::SEED_PREFIX.as_bytes(), mint.key().as_ref()],
        bump,
    )]
    pub vault: SystemAccount<'info>,

    #[account(
        mut,
        associated_token::mint = wsol_mint,
        associated_token::authority = vault
    )]
    pub wsol_vault_token_account: Box<InterfaceAccount<'info, TokenAccount>>,

    /// CHECK
    #[account(
        mut,
        seeds = [
            vault.key().as_ref(),
            token_0_program.key().as_ref(),
            token_0_mint.key().as_ref(),
        ],
        bump,
        seeds::program = associated_token_program,
    )]
    pub creator_token_0: UncheckedAccount<'info>,

    /// CHECK
    #[account(
        mut,
        seeds = [
            vault.key().as_ref(),
            token_1_program.key().as_ref(),
            token_1_mint.key().as_ref(),
        ],
        bump,
        seeds::program = associated_token_program,
    )]
    pub creator_token_1: UncheckedAccount<'info>,

    /// CHECK
    #[account(
        mut,
        seeds = [
            vault.key().as_ref(),
            token_program.key().as_ref(),
            lp_mint.key().as_ref(),
        ],
        bump,
        seeds::program = associated_token_program,
    )]
    pub creator_lp_token: UncheckedAccount<'info>,

    /// CHECKED: amm config, passed to cp-swap, checked by the raydium initialize instruction
    #[account(
        owner = cp_swap_program.key(),
    )]
    pub amm_config: UncheckedAccount<'info>,

    /// CHECK: create pool fee account, will be checked in the raydium initialize instruction
    #[account(mut)]
    pub create_pool_fee: UncheckedAccount<'info>,

    /// CHECK: lp mint authority, passed to cp-swap
    #[account(
        seeds = [AUTH_SEED.as_bytes()],
        seeds::program = cp_swap_program,
        bump
    )]
    pub authority: UncheckedAccount<'info>,

    /// CHECK: Initialize an account to store the pool state, init by cp-swap
    #[account(
        mut,
        seeds = [
            POOL_SEED.as_bytes(),
            amm_config.key().as_ref(),
            token_0_mint.key().as_ref(),
            token_1_mint.key().as_ref(),
        ],
        seeds::program = cp_swap_program,
        bump,
    )]
    pub pool_state: UncheckedAccount<'info>,

    /// CHECK: an account to store oracle observations, init by cp-swap
    #[account(
        mut,
        seeds = [
            OBSERVATION_SEED.as_bytes(),
            pool_state.key().as_ref(),
        ],
        seeds::program = cp_swap_program,
        bump,
    )]
    pub observation_state: UncheckedAccount<'info>,

    /// CHECK: Token_0 vault for the pool, init by cp-swap
    #[account(
        mut,
        seeds = [
            POOL_VAULT_SEED.as_bytes(),
            pool_state.key().as_ref(),
            token_0_mint.key().as_ref()
        ],
        seeds::program = cp_swap_program,
        bump,
    )]
    pub token_0_vault: UncheckedAccount<'info>,

    /// CHECK: Token_1 vault for the pool, init by cp-swap
    #[account(
        mut,
        seeds = [
            POOL_VAULT_SEED.as_bytes(),
            pool_state.key().as_ref(),
            token_1_mint.key().as_ref()
        ],
        seeds::program = cp_swap_program,
        bump,
    )]
    pub token_1_vault: UncheckedAccount<'info>,

    /// Sysvar for program account
    pub rent: Sysvar<'info, Rent>,

    /// CHECK: Raydium swap program
    #[account(
        address = raydium_cp_swap::program::RaydiumCpSwap::id(),
    )]
    pub cp_swap_program: UncheckedAccount<'info>,

    pub associated_token_program: Program<'info, AssociatedToken>,
    /// Spl token program or token program 2022
    pub token_0_program: Interface<'info, TokenInterface>,
    /// Spl token program or token program 2022
    pub token_1_program: Interface<'info, TokenInterface>,
    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>,
}

pub fn handler(ctx: Context<MigrateToken>) -> Result<()> {
    let mint_key = ctx.accounts.mint.key();
    let vault = ctx.accounts.vault.to_account_info();
    let wsol_vault = ctx.accounts.wsol_vault_token_account.to_account_info();

    let vault_seeds: &[&[&[u8]]] = &[&[
        Vault::SEED_PREFIX.as_bytes(),
        mint_key.as_ref(),
        &[ctx.bumps.vault],
    ]];

    let cpi_accounts = cpi::accounts::Initialize {
        creator: vault,
        amm_config: ctx.accounts.amm_config.to_account_info(),
        authority: ctx.accounts.authority.to_account_info(),
        pool_state: ctx.accounts.pool_state.to_account_info(),
        token_0_mint: ctx.accounts.token_0_mint.to_account_info(),
        token_1_mint: ctx.accounts.token_1_mint.to_account_info(),
        lp_mint: ctx.accounts.lp_mint.to_account_info(),
        creator_token_0: ctx.accounts.creator_token_0.to_account_info(),
        creator_token_1: ctx.accounts.creator_token_1.to_account_info(),
        creator_lp_token: ctx.accounts.creator_lp_token.to_account_info(),
        token_0_vault: ctx.accounts.token_0_vault.to_account_info(),
        token_1_vault: ctx.accounts.token_1_vault.to_account_info(),
        create_pool_fee: ctx.accounts.create_pool_fee.to_account_info(),
        observation_state: ctx.accounts.observation_state.to_account_info(),
        token_program: ctx.accounts.token_program.to_account_info(),
        token_0_program: ctx.accounts.token_0_program.to_account_info(),
        token_1_program: ctx.accounts.token_1_program.to_account_info(),
        associated_token_program: ctx.accounts.associated_token_program.to_account_info(),
        system_program: ctx.accounts.system_program.to_account_info(),
        rent: ctx.accounts.rent.to_account_info(),
    };

    let cpi_context = CpiContext::new_with_signer(
        ctx.accounts.cp_swap_program.to_account_info(),
        cpi_accounts,
        vault_seeds,
    );

    let amount_0: u64;
    let amount_1: u64;
    if ctx.accounts.token_0_mint.key() == NATIVE_MINT {
        amount_0 = 9 * LAMPORTS_PER_SOL;
        amount_1 = 1_000_000_000;
    } else {
        amount_0 = 1_000_000_000;
        amount_1 = 9 * LAMPORTS_PER_SOL;
    }

    cpi::initialize(
        cpi_context,
        amount_0,
        amount_1,
        Clock::get()?.unix_timestamp as u64,
    )?;

    Ok(())
}

Explanation: This migrate instruction performs a Cross-Program Invocation (CPI) to Raydium's CPMM program to create a new liquidity pool. It sets up all required accounts, calculates token amounts based on which token is SOL, and invokes the initialize function to atomically create the pool with the stored assets from the vault.


Everything appears ready. However, when we execute the migration instruction, we encounter below error. What went wrong?

AnchorError

Account: vault
Error Code: AccountNotSystemOwned
Error Number: 3011
Message: The given account is not owned by the system program.

Let's correct the error

The error message is clear: Account vault is not AccountNotSystemOwned.

The migrate instruction requires vault to be owned by the SystemProgram.

initialize.rs Rust
#[derive(Accounts)]
pub struct Initialize<'info> {
    /// Address paying to create the pool. Can be anyone
    #[account(mut)]
    pub creator: Signer<'info>,

    .....................................................

    /// pool lp mint
    #[account(
        init,
        seeds = [
            POOL_LP_MINT_SEED.as_bytes(),
            pool_state.key().as_ref(),
        ],
        bump,
        mint::decimals = 9,
        mint::authority = authority,
        payer = creator,
        mint::token_program = token_program,
    )]
    pub lp_mint: Box<InterfaceAccount<'info, Mint>>,

    .....................................................
}

Explanation: This snippet from Raydium's initialize instruction shows that the creator account (which is our vault) must be a Signer that pays for initializing the LP mint. Only SystemAccounts can pay initialization fees, but our vault was already initialized as a Program State Account.


Why? vault is declared as creator in the initialize instruction, and this creator is responsible for paying the creation fee for the lp_token. Only a SystemAccount can pay that fee.

However, vault is a Program State Account managed by the FPump program because we have already initialized it. To fix this, we simply skip the init step. By default, all uninitialized accounts are owned by the SystemProgram. With this change, everything works perfectly. You can check the master branch for the final code.

Please check the branch main for more details

create_token.rs Rust

#[derive(Accounts)]
pub struct CreateToken<'info> {
    .....................................................
    #[account(
        mut,
        seeds = [Vault::SEED_PREFIX.as_bytes(), mint.key().as_ref()],
        bump,
    )]
    pub vault: SystemAccount<'info>,
    .....................................................
}

Explanation: The fix is simple: change the vault account type from Account<'info, Vault> to SystemAccount<'info> and remove the init constraint. This keeps the vault as a SystemAccount (owned by the System Program), allowing it to pay fees while still functioning as a PDA for signing operations.

Conclusion

Successfully migrating assets from a Pump.fun-style program to Raydium requires understanding two critical concepts: Solana's account ownership model and proper instruction construction. The key insight is recognizing that only SystemAccounts can pay transaction fees and rent fees—a requirement that necessitates careful account initialization.

By storing assets by a SystemAccount-owned PDA account and invoking Raydium's CPMM initialize instruction via CPI, we achieve atomic liquidity migration. This pattern enables automated market making without manual intervention, creating a seamless transition from bonding curve trading to full DEX liquidity.