How to Build a Bonding Curve on Solana
A bonding curve is the simplest on-chain market maker. You deposit SOL into a program, it mints you tokens at a price set by a mathematical formula. You sell tokens back, the program burns them and returns SOL. No order book, no liquidity pools, no counterparty. Just a curve.
This article walks through building one from scratch using Anchor on Solana. By the end you will have a working program with token creation, buy and sell instructions, a logistic map pricing function, and a creator royalty. Every line of code shown here is real Rust that compiles and deploys.
How Bonding Curves Work
The core idea is simple. A bonding curve holds two things: a pool of SOL and a supply of tokens. The price of the token is a function of some state variable, not of the supply ratio. When you buy, the program computes the current price, takes your SOL, mints tokens at that price, and advances the state. When you sell, the program burns your tokens, computes the current price, and returns SOL from reserves.
This is different from a constant-product AMM like Uniswap. In a constant-product pool, price is a ratio of reserves. In a bonding curve, price is a function of an independent state variable that advances with every trade. The curve can be any function you want — linear, exponential, sinusoidal, or a logistic map.
A bonding curve program has three instructions — create, buy, sell — and on-chain state (reserves, supply, pricing params). The pricing function can be anything: linear, exponential, sigmoid. In our implementation we use a logistic map (skip to The Math if you want to understand why). Every trade advances the state, which changes the price for the next trader.
The Anchor Project Setup
Start with a standard Anchor project. Your Cargo.toml needs anchor-lang and anchor-spl:
[package]
name = "solana-program"
version = "0.1.0"
edition = "2021"
[dependencies]
anchor-lang = "0.30.1"
anchor-spl = "0.30.1"
The program ID in lib.rs declares the on-chain address. The top-level module wires up the instructions, math, state, constants, and errors:
pub mod constants;
pub mod error;
pub mod instructions;
pub mod math;
pub mod state;
use anchor_lang::prelude::*;
declare_id!("DVyfzLPDod3MpCK1uqthnMwHpVcjSHRcyoHQxotSmD6g");
#[program]
pub mod chaotic_bonding_curve {
use super::*;
pub fn create_token(
ctx: Context<CreateToken>,
name: String,
symbol: String,
r_value: u64,
) -> Result<()> {
instructions::create_token::handler(ctx, name, symbol, r_value)
}
pub fn buy(ctx: Context<Buy>, sol_amount: u64) -> Result<()> {
instructions::buy::buy_handler(ctx, sol_amount)
}
pub fn sell(ctx: Context<Sell>, token_amount: u64) -> Result<()> {
instructions::sell::sell_handler(ctx, token_amount)
}
}
Three instructions: create_token initializes the curve, buy mints tokens for SOL, sell burns tokens for SOL.
Constants and Seeds
Every Anchor program starts with constants. PDA seeds define how accounts are derived. The token parameters set decimals and the initial state of the curve.
use anchor_lang::prelude::*;
#[constant]
pub const BONDING_CURVE_SEED: &[u8] = b"bonding-curve";
#[constant]
pub const MINT_SEED: &[u8] = b"mint";
pub const MAX_NAME_LEN: usize = 32;
pub const MAX_SYMBOL_LEN: usize = 10;
#[constant]
pub const INITIAL_X: u64 = 500_000_000; // x = 0.5 (midpoint)
#[constant]
pub const TOKEN_DECIMALS: u8 = 6;
The BONDING_CURVE_SEED and MINT_SEED are used to derive PDA addresses. INITIAL_X starts the curve at its midpoint. TOKEN_DECIMALS of 6 matches the convention used by most SPL tokens.
The State Account
The BondingCurve account stores everything the program needs to know about a single curve. It is a PDA derived from the creator's public key and the token symbol, which means each creator can have multiple curves with different symbols.
use crate::constants::*;
use anchor_lang::prelude::*;
#[account]
pub struct BondingCurve {
pub creator: Pubkey, // 32 bytes — royalty recipient
pub mint: Pubkey, // 32 bytes — SPL token mint address
pub r_value: u64, // 8 bytes — curve shape parameter
pub current_x: u64, // 8 bytes — logistic map state
pub sol_reserves: u64, // 8 bytes — SOL in the curve (lamports)
pub token_supply: u64, // 8 bytes — current circulating token supply
pub bump: u8, // 1 byte — PDA bump
pub mint_bump: u8, // 1 byte — PDA bump for the mint
pub name: [u8; MAX_NAME_LEN], // 32 bytes
pub symbol: [u8; MAX_SYMBOL_LEN], // 10 bytes
}
impl BondingCurve {
pub const LEN: usize = 8 + 32 + 32 + (8 * 4) + 1 + 1 + MAX_NAME_LEN + MAX_SYMBOL_LEN;
pub fn name_string(&self) -> String {
let end = self.name.iter().position(|&b| b == 0).unwrap_or(self.name.len());
String::from_utf8_lossy(&self.name[..end]).to_string()
}
pub fn symbol_string(&self) -> String {
let end = self.symbol.iter().position(|&b| b == 0).unwrap_or(self.symbol.len());
String::from_utf8_lossy(&self.symbol[..end]).to_string()
}
}
The account stores:
creatorandmint— the two most important pubkeysr_value— controls the shape of the logistic map (more on this below)current_x— the logistic map state, which determines the current pricesol_reservesandtoken_supply— track the curve's balance sheetbumpandmint_bump— cached PDA bumps so we don't recompute them on every instructionnameandsymbol— fixed-length byte arrays for the token metadata
Fixed-length byte arrays are used instead of String because Anchor serialization needs deterministic sizes.
The Math: Logistic Map and Exponential Pricing
The bonding curve uses two mathematical functions: one that advances the state and one that computes price from the state. Both use fixed-point arithmetic with a scale of 10^9 to avoid floating-point operations, which are not available on-chain.
Fixed-Point Scale
pub const SCALE: u64 = 1_000_000_000;
pub const BASE_PRICE: u64 = 100_000_000; // 0.1 SOL
pub const K: u64 = 3_200_000_000; // 3.2 * SCALE — steepness
pub const R_MIN: u64 = 3_570_000_000; // 3.57 * SCALE
pub const R_MAX: u64 = 4_000_000_000; // 4.00 * SCALE
All values are integers scaled by 10^9. A value of 500_000_000 represents 0.5. A value of 3_570_000_000 represents 3.57. BASE_PRICE is 0.1 SOL in lamports — the price at the midpoint of the curve. K controls how steeply price changes as the state moves.
Advancing the State
The logistic map is a recurrence relation: x' = r * x * (1 - x). It is one of the simplest functions that produces complex behavior. At r values between 3.57 and 4.0, the map is chaotic — tiny differences in initial x produce completely different trajectories. At lower r values, it converges to cycles or fixed points.
pub fn advance_x(x: u64, r: u64) -> u64 {
let x_u128 = x as u128;
let r_u128 = r as u128;
let scale_u128 = SCALE as u128;
let one_minus_x = scale_u128 - x_u128;
let product = r_u128 * x_u128 * one_minus_x;
(product / (scale_u128 * scale_u128)) as u64
}
The function uses u128 intermediates to avoid overflow. The product r * x * (1-x) can be large since all three terms are in the billions, so the division by SCALE^2 brings it back into the [0, SCALE] range.
Computing Price
The price function maps the current state x to a token price in lamports:
P(x) = BASE_PRICE * exp(k * (x - 0.5))
where k controls the steepness. At the midpoint (x = 0.5), the exponent is zero and price equals BASE_PRICE. As x moves higher, price increases exponentially. As x moves lower, price decreases. The exponential is approximated with a 5th-order Taylor series, which gives less than 0.1% error for the range of values we care about.
pub fn get_price(x: u64) -> u64 {
let scale = SCALE as u128;
let x_u128 = x as u128;
let k_u128 = K as u128;
let base_u128 = BASE_PRICE as u128;
let half_scale = scale / 2;
let diff = if x_u128 >= half_scale {
x_u128 - half_scale
} else {
let neg_diff = half_scale - x_u128;
let y = (k_u128 * neg_diff) / scale;
let exp_pos = exp_taylor(y, scale);
return ((base_u128 * scale) / exp_pos) as u64;
};
let y = (k_u128 * diff) / scale;
let exp_val = exp_taylor(y, scale);
((base_u128 * exp_val) / scale) as u64
}
fn exp_taylor(y: u128, scale: u128) -> u128 {
let mut result = scale;
let mut term = scale;
// term_1 = y
term = (term * y) / scale;
result += term;
// term_2 = y^2 / 2
term = (term * y) / (2 * scale);
result += term;
// term_3 = y^3 / 6
term = (term * y) / (3 * scale);
result += term;
// term_4 = y^4 / 24
term = (term * y) / (4 * scale);
result += term;
// term_5 = y^5 / 120
term = (term * y) / (5 * scale);
result += term;
result
}
The key property of this price function: price is monotonic in x. Higher x always means higher price. The logistic map controls where x goes, and the exponential controls what that means for price. Together they produce a curve where every trade changes the price for the next trader, and the sequence of prices depends on the entire history of trades.
Why a logistic map instead of a simpler function like price = supply^2 or a linear ramp? Because it creates price trajectories that are path-dependent and non-repeating. Two curves with the same r but different trade histories will have different prices. Two curves with the same r and the same initial x will diverge after different sequences of buys and sells. The pricing is deterministic but unpredictable — you can compute the next price given the current state, but you cannot predict the price 10 trades from now without simulating all 10 trades.
Error Codes
Anchor uses an #[error_code] enum for program errors. These get embedded in the IDL and returned to clients as structured errors.
use anchor_lang::prelude::*;
#[error_code]
pub enum ChaosError {
#[msg("Name must be between 1 and 32 characters")]
NameTooLong,
#[msg("Symbol must be between 1 and 10 characters")]
SymbolTooLong,
#[msg("r-value must be between 3.57 and 4.0")]
InvalidRValue,
#[msg("Insufficient SOL provided for the trade")]
InsufficientFunds,
#[msg("Insufficient token balance for the trade")]
InsufficientTokens,
#[msg("Arithmetic overflow")]
MathOverflow,
}
Create Token Instruction
The create_token instruction initializes a new bonding curve. It creates two PDAs: the bonding curve account and the SPL token mint. The bonding curve PDA is derived from the creator's key and the token symbol. The mint PDA is derived from the bonding curve's address, which means the mint address is deterministic once you know the curve address.
#[derive(Accounts)]
#[instruction(name: String, symbol: String, r_value: u64)]
pub struct CreateToken<'info> {
#[account(mut)]
pub creator: Signer<'info>,
#[account(
init,
payer = creator,
space = BondingCurve::LEN,
seeds = [
BONDING_CURVE_SEED,
creator.key().as_ref(),
symbol.as_bytes()
],
bump
)]
pub bonding_curve: Account<'info, BondingCurve>,
#[account(
init,
payer = creator,
mint::decimals = TOKEN_DECIMALS,
mint::authority = bonding_curve,
seeds = [
MINT_SEED,
bonding_curve.key().as_ref()
],
bump
)]
pub mint: Account<'info, Mint>,
/// CHECK: Reserved for Metaplex metadata
#[account(mut)]
pub metadata: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
pub token_program: Program<'info, Token>,
pub rent: Sysvar<'info, Rent>,
}
The handler validates inputs, copies the name and symbol into fixed-length byte arrays, and initializes the bonding curve fields:
pub fn handler(
ctx: Context<CreateToken>,
name: String,
symbol: String,
r_value: u64,
) -> Result<()> {
let bonding_curve = &mut ctx.accounts.bonding_curve;
require!(name.len() > 0 && name.len() <= MAX_NAME_LEN, ChaosError::NameTooLong);
require!(symbol.len() > 0 && symbol.len() <= MAX_SYMBOL_LEN, ChaosError::SymbolTooLong);
require!(
r_value >= math::R_MIN && r_value <= math::R_MAX,
ChaosError::InvalidRValue
);
let mut name_bytes = [0u8; MAX_NAME_LEN];
name_bytes[..name.len()].copy_from_slice(name.as_bytes());
let mut symbol_bytes = [0u8; MAX_SYMBOL_LEN];
symbol_bytes[..symbol.len()].copy_from_slice(symbol.as_bytes());
bonding_curve.creator = ctx.accounts.creator.key();
bonding_curve.mint = ctx.accounts.mint.key();
bonding_curve.r_value = r_value;
bonding_curve.current_x = INITIAL_X;
bonding_curve.sol_reserves = 0;
bonding_curve.token_supply = 0;
bonding_curve.bump = ctx.bumps.bonding_curve;
bonding_curve.mint_bump = ctx.bumps.mint;
bonding_curve.name = name_bytes;
bonding_curve.symbol = symbol_bytes;
Ok(())
}
The r_value is constrained to [3.57, 4.0]. This is the range where the logistic map exhibits complex, non-repeating behavior. You can widen or narrow this range depending on how predictable you want the price trajectory to be.
The mint authority is set to the bonding curve PDA. This means only the program can mint tokens, and only when the PDA signs the CPI. No one else can mint.
Buy Instruction
The buy instruction is the core of the program. Here is what happens when someone buys:
The buy (green) and sell (red) flows: both compute price from the logistic map state, execute the trade, and advance the state. Only the buy side includes a 0.1% creator royalty.
- Compute the current token price from the logistic map state
- Calculate how many tokens the SOL buys at that price
- Split off 0.1% as a creator royalty
- Transfer SOL from the buyer to the bonding curve PDA
- Send the royalty to the creator
- Mint tokens to the buyer (the PDA signs as the mint authority)
- Advance the logistic map state
- Update reserves and supply
#[derive(Accounts)]
pub struct Buy<'info> {
#[account(mut)]
pub buyer: Signer<'info>,
#[account(
mut,
seeds = [
BONDING_CURVE_SEED,
bonding_curve.creator.key().as_ref(),
bonding_curve.symbol.as_ref()
],
bump = bonding_curve.bump,
)]
pub bonding_curve: Account<'info, BondingCurve>,
#[account(
mut,
seeds = [
MINT_SEED,
bonding_curve.key().as_ref()
],
bump = bonding_curve.mint_bump,
)]
pub mint: Account<'info, Mint>,
#[account(
init_if_needed,
payer = buyer,
associated_token::mint = mint,
associated_token::authority = buyer,
)]
pub buyer_token_account: Account<'info, TokenAccount>,
/// CHECK: Creator receives royalty — SOL transfer destination only
#[account(mut)]
pub creator: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
pub token_program: Program<'info, Token>,
pub associated_token_program: Program<'info, AssociatedToken>,
}
The buyer_token_account uses init_if_needed so the buyer doesn't need to pre-create an ATA. The creator is an UncheckedAccount because we only transfer SOL to it — we never read its data.
The handler:
pub fn buy_handler(ctx: Context<Buy>, sol_amount: u64) -> Result<()> {
let bonding_curve = &mut ctx.accounts.bonding_curve;
let bump = bonding_curve.bump;
// 1. Compute current price
let price = math::get_price(bonding_curve.current_x);
// 2. Calculate tokens to mint
let token_scale = 10u64.pow(TOKEN_DECIMALS as u32);
let tokens_to_mint = ((sol_amount as u128 * token_scale as u128) / price as u128) as u64;
require!(tokens_to_mint > 0, ChaosError::InsufficientFunds);
// 3. Calculate royalty: 0.1%
let royalty = sol_amount / 1000;
let reserves_amount = sol_amount - royalty;
// 4. Transfer SOL from buyer to bonding curve PDA
anchor_lang::system_program::transfer(
CpiContext::new(
ctx.accounts.system_program.key(),
anchor_lang::system_program::Transfer {
from: ctx.accounts.buyer.to_account_info(),
to: bonding_curve.to_account_info(),
},
),
sol_amount,
)?;
// 5. Send royalty to creator
if royalty > 0 {
**bonding_curve.to_account_info().try_borrow_mut_lamports()? -= royalty;
**ctx.accounts.creator.try_borrow_mut_lamports()? += royalty;
}
// 6. Mint tokens to buyer (PDA signs)
let signer_seeds: &[&[u8]] = &[
BONDING_CURVE_SEED,
bonding_curve.creator.as_ref(),
bonding_curve.symbol.as_ref(),
&[bump],
];
token::mint_to(
CpiContext::new_with_signer(
ctx.accounts.token_program.key(),
token::MintTo {
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.buyer_token_account.to_account_info(),
authority: bonding_curve.to_account_info(),
},
&[&signer_seeds[..]],
),
tokens_to_mint,
)?;
// 7. Advance the logistic map
bonding_curve.current_x = math::advance_x(bonding_curve.current_x, bonding_curve.r_value);
// 8. Update reserves and supply
bonding_curve.sol_reserves = bonding_curve
.sol_reserves
.checked_add(reserves_amount)
.ok_or(ChaosError::MathOverflow)?;
bonding_curve.token_supply = bonding_curve
.token_supply
.checked_add(tokens_to_mint)
.ok_or(ChaosError::MathOverflow)?;
Ok(())
}
The royalty is 0.1% — 10 basis points on every buy. It is a fixed fraction, not configurable per-curve. The royalty is sent to the creator's wallet, not held in the curve. This means the creator earns SOL on every trade, not just on the initial mint.
The token minting uses a CPI with PDA signature. The bonding curve account signs as the mint authority using the seeds that were used to derive it. Anchor verifies these seeds match the account's address via the bump constraint on the bonding_curve account in the #[derive(Accounts)] struct.
Sell Instruction
Selling is the inverse of buying. The seller sends tokens, the program burns them and returns SOL:
#[derive(Accounts)]
pub struct Sell<'info> {
#[account(mut)]
pub seller: Signer<'info>,
#[account(
mut,
seeds = [
BONDING_CURVE_SEED,
bonding_curve.creator.key().as_ref(),
bonding_curve.symbol.as_ref()
],
bump = bonding_curve.bump,
)]
pub bonding_curve: Account<'info, BondingCurve>,
#[account(
mut,
seeds = [
MINT_SEED,
bonding_curve.key().as_ref()
],
bump = bonding_curve.mint_bump,
)]
pub mint: Account<'info, Mint>,
#[account(
mut,
associated_token::mint = mint,
associated_token::authority = seller,
)]
pub seller_token_account: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
}
Notice that the sell instruction does not include the creator account. There is no royalty on sells. Only buys pay the creator.
The handler:
pub fn sell_handler(ctx: Context<Sell>, token_amount: u64) -> Result<()> {
let bonding_curve = &mut ctx.accounts.bonding_curve;
// 1. Compute current price
let price = math::get_price(bonding_curve.current_x);
// 2. Calculate SOL return
let token_scale = 10u64.pow(TOKEN_DECIMALS as u32);
let sol_return = ((token_amount as u128 * price as u128) / token_scale as u128) as u64;
require!(sol_return > 0, ChaosError::InsufficientFunds);
require!(
sol_return <= bonding_curve.sol_reserves,
ChaosError::InsufficientFunds
);
// 3. Burn tokens from seller
token::burn(
CpiContext::new(
ctx.accounts.token_program.key(),
token::Burn {
mint: ctx.accounts.mint.to_account_info(),
from: ctx.accounts.seller_token_account.to_account_info(),
authority: ctx.accounts.seller.to_account_info(),
},
),
token_amount,
)?;
// 4. Transfer SOL from bonding curve PDA to seller
**bonding_curve.to_account_info().try_borrow_mut_lamports()? -= sol_return;
**ctx.accounts.seller.try_borrow_mut_lamports()? += sol_return;
// 5. Advance the logistic map
bonding_curve.current_x = math::advance_x(bonding_curve.current_x, bonding_curve.r_value);
// 6. Update reserves and supply
bonding_curve.sol_reserves = bonding_curve
.sol_reserves
.checked_sub(sol_return)
.ok_or(ChaosError::MathOverflow)?;
bonding_curve.token_supply = bonding_curve
.token_supply
.checked_sub(token_amount)
.ok_or(ChaosError::MathOverflow)?;
Ok(())
}
Burning uses the seller's signature as authority over their token account. The bonding curve PDA does not need to sign because burning tokens only requires the token account owner's signature. The SOL transfer is a direct lamport manipulation on the bonding curve PDA — no CPI needed since the program owns the PDA's lamports by virtue of being the program the PDA belongs to.
Every sell also advances the logistic map state. This means selling changes the price for the next buyer, just like buying does. The state is advanced on every trade regardless of direction.
PDA Design: Why Seeds Matter
The PDA seed structure determines what is unique and what is discoverable.
_The two-level PDA hierarchy: the BondingCurve PDA is derived from ["bonding-curve", creator, symbol], and the Mint PDA is derived from ["mint", bonding_curve.address] — a parent-child relationship that guarantees a deterministic 1:1 mapping.
The bonding curve PDA uses [BONDING_CURVE_SEED, creator, symbol]. This means:
- Each creator can have multiple curves with different symbols
- A given (creator, symbol) pair can only have one curve
- Anyone can derive the curve address given the creator's pubkey and the symbol
The mint PDA uses [MINT_SEED, bonding_curve]. This means:
- Each curve has exactly one mint
- The mint address can be derived from the curve address
- No one can create a mint that collides with an existing curve's mint
This is a common pattern in Anchor: parent-child PDA relationships where the child's seed includes the parent's address. It creates a deterministic, one-to-one mapping that is cheap to verify on-chain.
Putting It Together
Here is the full flow for creating and trading on a bonding curve:
Create: The creator calls create_token with a name, symbol, and r-value (between 3.57 and 4.0). The program creates the bonding curve account and the mint. The curve starts at x = 0.5, mid-range, where price equals BASE_PRICE (0.1 SOL).
Buy: A buyer calls buy with some SOL. The program computes the current price, calculates token output, takes the SOL, sends 0.1% to the creator, mints tokens to the buyer, and advances the logistic map. The next buyer will face a different price.
Sell: A holder calls sell with some tokens. The program computes the current price, calculates SOL return, burns the tokens, sends SOL from reserves, and advances the logistic map. No royalty on sells.
Generating the IDL
Anchor automatically generates an IDL (Interface Description Language) JSON file when you build. This IDL is your program's public API — clients, frontends, and other programs use it to know what instructions exist, what accounts they need, and how data is structured.
anchor build
This produces target/idl/chaotic_bonding_curve.json. Publish it to the chain so explorers and wallets can decode your instructions:
anchor idl init --provider.cluster devnet \
--filepath target/idl/chaotic_bonding_curve.json \
DVyfzLPDod3MpCK1uqthnMwHpVcjSHRcyoHQxotSmD6g
Once the IDL is on-chain, anyone can call your program. A TypeScript client can be generated with:
anchor idl type target/idl/chaotic_bonding_curve.json \
--out ./src/idl/chaotic_bonding_curve.ts
Extensions and Next Steps
This is a minimal bonding curve. The structure is meant to be modified — swap the pricing function, adjust the royalty, add a graduation threshold. Here are practical next steps ordered by difficulty:
1. Swap the pricing function. The math::get_price function is the only thing you need to change to get a different curve. Replace the logistic map with a linear ramp:
fn get_price(x: u64) -> u64 {
// Linear: price starts at 0 and increases proportionally with x
(BASE_PRICE as u128 * x as u128 / SCALE as u128) as u64
}
Or a simple exponential that uses supply as state:
fn get_price(supply: u64) -> u64 {
// price = BASE * e^(supply / K)
// plug in your own exp_taylor, same as before
}
The rest of the program — accounts, CPIs, royalty splitting, reserve tracking — stays the same. You are only changing one pure function.
2. Graduate to Raydium. When the curve hits a target (e.g. 10 SOL in reserves), open a Raydium pool seeded with the curve's SOL and tokens, burn the LP, and disable further curve trades.
3. Add sell-side royalty. Mirror the 0.1% royalty on sells. Same logic as buy — compute royalty = sol_return / 1000, transfer it to the creator before sending SOL to the seller.
4. Metaplex metadata. After creating the mint in create_token, CPI into the Token Metadata Program to set on-chain name, symbol, and URI. This makes your token show up correctly in wallets.
5. Frontend. Generate the TypeScript client from the IDL (step above), then wire it to a React or Next.js UI. Anchor's TypeScript SDK gives you typed methods for every instruction — program.methods.buy(new anchor.BN(solAmount)).accounts({...}).rpc().
Deploying
anchor build
anchor deploy --provider.cluster devnet
anchor idl init --provider.cluster devnet \
--filepath target/idl/chaotic_bonding_curve.json \
DVyfzLPDod3MpCK1uqthnMwHpVcjSHRcyoHQxotSmD6g
Your bonding curve is live. Call create_token from the Anchor TS client or Solana CLI, then have someone buy and sell. The logistic map will advance on every trade, producing a price trajectory that cannot be predicted in advance — only computed step by step.