Control · Integration
v1.0 · Mainnet + Devnet · Last updated 2026-05-05 (CreateToken now 13 accounts · community-pool pre-allocated · no min trade size) GitHub
External Integration Guide

Integrate Control tokens with direct on-chain Buy and Sell.

Control is an on-chain bonding-curve token launchpad on Solana. This guide documents everything an external client — DEX aggregator, trading bot, portfolio tool — needs to route Buy and Sell orders for Control tokens directly through the on-chain program, with no off-chain backend in the hot path.

Chain
Solana
Token Standard
Token-2022
Graduation
per-token
Framework
Pinocchio

01Overview

What Control is and what this document covers.

Control is a bonding-curve token launchpad on Solana. Each token trades against a deterministic AMM curve embedded in the on-chain program. Every trade adjusts the curve's reserves; price is a pure function of those reserves.

When a curve accumulates its required liquidity threshold in real reserves it graduates: the remaining supply and pooled SOL are migrated into a Meteora DAMM v2 pool, and the token continues life as a standard AMM asset. The threshold is configured per-token in the curve state (curve.required_liquidity) — default 95 SOL, with hard bounds 0.1 SOL ≤ required_liquidity ≤ 10,000 SOL set by the program admin at curve creation. The same bounds apply on Devnet and Mainnet — the only per-network difference is the choice of value, not the range. Before graduation, all trading goes through Control. After graduation, you should route through Meteora DAMM v2.

i

What's in scope? Integrators only call Buy and Sell. Token creation and admin instructions are out of scope for this guide.

Who signs trades? The end user's wallet — Control is fully non-custodial.

02Scope of v1

This document covers only what you need to trade. Advanced features ship later.

In scope (v1)
Buy Swap SOL → token on the bonding curve
Sell Swap token → SOL on the bonding curve
PDA derivation Client-side PDAs needed to build both instructions
Curve math AMM formula + fee breakdown for quoting
Out of scope (future)
REST API Token metadata & market-data endpoints
WebSocket New-token and graduation events
/buildTx endpoint Server-side transaction construction
SDK TypeScript / Rust client library
!

Discovery is your responsibility in v1. Until the public API ships, you are expected to discover Control mints via your own Solana RPC indexing (for example, subscribing to the program's account updates) or via the dev team.

03Program

The Control program on Solana.

Field Value
Networks Mainnet + Devnetsame program ID on both
Program ID CTRL5CCEQw5zhhBeEV8n5GKZpf3E5tYQoXhhxzUAps27
Framework Pinocchio (raw BPF — not Anchor)
Instruction disc 8-byte Anchor sighash (sha256("global:<name>")[..8]) — what the IDL declares and what Solscan/SolanaFM decode against. Legacy 1-byte enum disc (0x02 Buy, 0x03 Sell, etc.) is also accepted by the on-chain dispatcher for backward compatibility. See the per-instruction cards in §05/§06 for the full byte arrays.
Token program TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb (Token-2022)
System program 11111111111111111111111111111111
i

Pinocchio, not Anchor. The on-chain IDL declares an 8-byte Anchor-style discriminator (sha256("global:<name>")[..8]) followed by little-endian primitive args (manually parsed via bytemuck, not Borsh). The on-chain dispatcher also accepts a single-byte legacy form (0x02 Buy, 0x03 Sell, etc.) so old clients keep working — both forms are documented on each instruction card below.

04PDA Derivation

All PDAs are derived deterministically from the token mint. Compute them client-side — no off-chain calls required.

The program's trading state is keyed by the token mint. Once you know the mint, you can derive every account needed for Buy and Sell in pure code.

Accounts you must derive

Account Seeds Program
config [b"control-config"] Control
curve [b"control-curve", mint] Control
vaultAta getAssociatedTokenAddressSync(mint, curve, true, TOKEN_2022_PROGRAM_ID) ATA (Token-2022)
userAta getAssociatedTokenAddressSync(mint, user, false, TOKEN_2022_PROGRAM_ID) ATA (Token-2022)
creatorFeeVault [b"creator-fees", mint, creator] Control
lpEscrow [b"lp-escrow", mint] Control
communityPool [b"community-pool", mint] Control
eventAuthority [b"__event_authority"] Control

eventAuthority is global to the program (not per-mint) and is required on every Buy and Sell. The bump is precomputed (255) and hardcoded in the program; you only need the address. Find it once and cache it.

!

Token-2022 ATAs. Pass the Token-2022 program ID, not the legacy SPL Token program. The vaultAta uses allowOwnerOffCurve = true because curve is a PDA, not a keypair.

Budget for ATA rent. The first time a wallet ever buys a given Control mint, the Buy transaction also pays ~2,039,280 lamports (~0.002 SOL) of rent to allocate the user's Token-2022 ATA via the idempotent createAssociatedTokenAccountIdempotentInstruction in the same tx. Subsequent Buys reuse the existing ATA and don't pay rent. Bots and aggregators with tight per-trade SOL budgets should account for this on the first trade.

Reference implementation

TypeScript
import { PublicKey } from '@solana/web3.js';
import {
  getAssociatedTokenAddressSync,
  TOKEN_2022_PROGRAM_ID,
} from '@solana/spl-token';

export const CONTROL_PROGRAM_ID = new PublicKey(
  'CTRL5CCEQw5zhhBeEV8n5GKZpf3E5tYQoXhhxzUAps27',
);

export function deriveConfig(): PublicKey {
  const [pda] = PublicKey.findProgramAddressSync(
    [Buffer.from('control-config')],
    CONTROL_PROGRAM_ID,
  );
  return pda;
}

export function deriveCurve(mint: PublicKey): PublicKey {
  const [pda] = PublicKey.findProgramAddressSync(
    [Buffer.from('control-curve'), mint.toBuffer()],
    CONTROL_PROGRAM_ID,
  );
  return pda;
}

export function deriveCreatorFeeVault(
  mint: PublicKey,
  creator: PublicKey,
): PublicKey {
  const [pda] = PublicKey.findProgramAddressSync(
    [Buffer.from('creator-fees'), mint.toBuffer(), creator.toBuffer()],
    CONTROL_PROGRAM_ID,
  );
  return pda;
}

export function deriveLpEscrow(mint: PublicKey): PublicKey {
  const [pda] = PublicKey.findProgramAddressSync(
    [Buffer.from('lp-escrow'), mint.toBuffer()],
    CONTROL_PROGRAM_ID,
  );
  return pda;
}

export function deriveCommunityPool(mint: PublicKey): PublicKey {
  const [pda] = PublicKey.findProgramAddressSync(
    [Buffer.from('community-pool'), mint.toBuffer()],
    CONTROL_PROGRAM_ID,
  );
  return pda;
}

// Anchor self-CPI signer for TradeEvent emission. Global, not per-mint.
// Required on every Buy and Sell.
export function deriveEventAuthority(): PublicKey {
  const [pda] = PublicKey.findProgramAddressSync(
    [Buffer.from('__event_authority')],
    CONTROL_PROGRAM_ID,
  );
  return pda;
}

export function deriveVaultAta(mint: PublicKey): PublicKey {
  const curve = deriveCurve(mint);
  return getAssociatedTokenAddressSync(
    mint,
    curve,
    true, // allowOwnerOffCurve — curve is a PDA
    TOKEN_2022_PROGRAM_ID,
  );
}

05Buy

Swap SOL for tokens on the bonding curve.

!

Breaking change — May 2026. Every Buy now mandatorily emits an Anchor self-CPI TradeEvent for explorer + indexer compatibility. Clients must include two extra accounts at the end of the account list (slots 12 and 13) — see the table below. Old callers that send 12 accounts will fail with NotEnoughAccountKeys. Buy is now 14 accounts.

i

Trade sizing. Any trade size from 1 lamport upward works on any Control mint, including the very first trade on a freshly created token — there is no minimum trade size. Prior to May 2026 a sub-~0.05 SOL first trade could fail with insufficient funds for rent because the community-fee slice was below the system-program rent-exempt minimum and would have lazy-created the community-pool PDA below rent. The contract now pre-allocates the community pool at CreateToken time, so this no longer happens.

i

Dual-mode discriminator. The on-chain dispatcher accepts both forms: a single-byte legacy disc (0x02) or the 8-byte Anchor disc (sha256("global:buy")[..8] = [102, 6, 61, 18, 1, 218, 235, 234]) — old clients keep working, Anchor SDK clients get IDL-driven decode for free. The IDL declares the 8-byte form, so Solscan / SolanaFM render swaps correctly out of the box. Examples below use the legacy 1-byte form for brevity; swap in the 8-byte prefix and everything else stays identical.

Buy

Discriminant 0x02 · Anchor [102, 6, 61, 18, 1, 218, 235, 234]
pre-graduation

Args (little-endian primitives, in order after the discriminant)

Name Type Description
sol_amount u64 SOL to spend, in lamports. Includes the fee portion.
min_tokens_out u64 Minimum tokens the user will accept. Use this as slippage protection — the program will revert if the curve cannot deliver at least this amount.

Accounts (in order)

# Name Flags Description
0 user signerw End user buying tokens. Pays SOL and fees.
1 config PDAr Program config — seeds [b"control-config"].
2 curve PDAw Control curve state — seeds [b"control-curve", mint]. Updated on every trade.
3 mint r Token-2022 mint.
4 vaultAta ATAw Curve's associated token account for mint (Token-2022). Owner is the curve PDA.
5 userAta ATAw End user's associated token account for mint (Token-2022). Must exist — create it atomically in the same transaction if needed.
6 creatorFeeVault PDAw Receives the creator's slice — seeds [b"creator-fees", mint, creator].
7 protocolFeeWallet w Protocol fee wallet (address available from config).
8 lpEscrow PDAw Accumulates the LP-migration slice — seeds [b"lp-escrow", mint].
9 communityPool PDAw Per-token community pool — seeds [b"community-pool", mint]. Receives both the base community fee (config.community_fee_bps) and the per-token addon (curve.extra_community_fee_bps).
10 token2022Program r TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb
11 systemProgram r 11111111111111111111111111111111
12 eventAuthority PDAr Anchor self-CPI signer — seeds [b"__event_authority"], bump 255 (precomputed). Required so the program can self-CPI back into itself with a TradeEvent log that Solscan/SolanaFM decode into a "Swap N for M SOL" UI.
13 program r CTRL5CCEQw5zhhBeEV8n5GKZpf3E5tYQoXhhxzUAps27

Instruction data layout

0x02 sol_amount u64 LE min_tokens_out u64 LE

Total: 1 + 8 + 8 = 17 bytes.

06Sell

Swap tokens for SOL on the bonding curve.

!

Breaking change — May 2026. Every Sell now mandatorily emits an Anchor self-CPI TradeEvent for explorer + indexer compatibility. Clients must include two extra accounts at the end of the account list (slots 11 and 12). Old callers that send 11 accounts will fail with NotEnoughAccountKeys. Sell is now 13 accounts.

Sell

Discriminant 0x03 · Anchor [51, 230, 133, 164, 1, 127, 131, 173]
pre-graduation

Args

Name Type Description
token_amount u64 Tokens to sell, in base units (respect the mint's decimals).
min_sol_out u64 Minimum SOL (lamports) the user will accept after fees.

Accounts (in order)

Sell takes 13 accounts — the first 10 match Buy's first 10 exactly. system_program is not required (the program credits SOL by mutating PDA lamports directly). Slots 11 and 12 are the same self-CPI accounts Buy carries at slots 12 and 13 (eventAuthority + program) — required for the TradeEvent emission.

# Name Flags
0usersignerw
1configPDAr
2curvePDAw
3mintr
4vaultAtaATAw
5userAtaATAw
6creatorFeeVaultPDAw
7protocolFeeWalletw
8lpEscrowPDAw
9communityPoolPDAw
10token2022Programr
11eventAuthorityPDAr
12programr

Instruction data layout

0x03 token_amount u64 LE min_sol_out u64 LE
!

Do not call Sell after graduation. Once the curve has migrated to Meteora DAMM v2, the program freezes and drains the curve, then sets is_completed = 1. Any further Buy/Sell against Control reverts. The curve account itself stays on chain — it is never closed — so the only correct check is the is_completed flag. Read it before every trade and route through Meteora DAMM v2 once it flips.

07Bonding Curve Math

Constant-product AMM with virtual reserves. Use this to pre-compute quotes before submitting transactions.

Formula

Control uses a standard x * y = k pool, except the reserves are the sum of virtual and real reserves. Virtual reserves set the initial price and smooth early trades; real reserves track actual SOL and tokens held by the curve.

Pseudocode
// BUY — user sends sol_in lamports, receives tokens_out
total_sol   = virtual_sol_reserve   + real_sol_reserve
total_token = virtual_token_reserve + real_token_reserve

tokens_out  = (total_token * sol_in) / (total_sol + sol_in)

// SELL — user sends token_in units, receives sol_out (before fees)
sol_out     = (total_sol * token_in) / (total_token + token_in)

Initial reserves

Parameter Value
virtual_sol_reserve 162 SOL
virtual_token_reserve ≈ 1,364,600,000 tokens
Scale factor e / ln(2)
real_sol_reserve (launch) 0 SOL
real_token_reserve (launch) total supply deposited in the curve's vault
i

Quoting tip. Read the curve account before every quote to get the current real reserves; virtual reserves are constant per-mint. Apply the fee structure below to the input before feeding it into the formula.

08Fee Structure

Default total: 3.00% per trade — five slices going to four destinations (community base + extra both land in communityPool). Per-token mints can shift the total via curve.extra_community_fee_bps.

Slice Rate Destination Source Purpose
Creator 0.15% creatorFeeVault config.creator_fee_bps Accrues to the token creator. Claimable via a separate instruction.
Protocol 0.60% protocolFeeWallet config.protocol_fee_bps Protocol fee accrued to the protocol treasury.
LP escrow 0.25% lpEscrow config.lp_fee_bps Reserved for seeding the Meteora pool at graduation.
Community (base) 1.00% communityPool config.community_fee_bps Per-token community pool — global rate.
Community (extra) 1.00% communityPool curve.extra_community_fee_bps Per-token addon stored in the curve state. Routes to the same communityPool PDA. (Formerly the "deployer tax" — now governed by the curve, not a constant.)
Total 3.00%

Fees are taken from the input side. For a Buy, the program takes the total fee out of sol_amount first and then prices the remainder against the curve; for a Sell, the program prices token_amount first and takes the total fee out of the resulting sol_out before paying the seller.

Per-slice arithmetic in TradeEvent. The contract computes each slice against the post-total-fee net, not against sol_amount:

Pseudocode (matches the on-chain processor)
total_fee_bps  = 200 + curve.extra_community_fee_bps   // = 300 for default mints
total_fee      = sol_amount * total_fee_bps / 10_000
curve_sol      = sol_amount − total_fee                // goes into the AMM

creator_fee         = config.creator_fee_bps          * curve_sol / 10_000
protocol_fee        = config.protocol_fee_bps         * curve_sol / 10_000
lp_fee              = config.lp_fee_bps               * curve_sol / 10_000
extra_community_fee = curve.extra_community_fee_bps   * curve_sol / 10_000
community_fee       = total_fee − (creator_fee + protocol_fee + lp_fee + extra_community_fee)
                      // community absorbs the integer-division rounding remainder

The total still equals (200 + extra_community_fee_bps) / 10_000 × sol_amount exactly, but each individual slice in the TradeEvent is bps × curve_sol, not bps × sol_amount. Community absorbs the dust — it can be slightly larger than 1.00% × curve_sol by up to a few lamports per trade. Anyone auditing per-slice numbers should reproduce this exact sequence; see the §10.4 TradeEvent decoder for the live lamport values.

09Graduation

When real SOL reserves reach the curve's required liquidity, Control migrates the token to Meteora DAMM v2.

The graduation threshold is per-token, stored on-chain as curve.required_liquidity. Read the curve account at quote time — don't hard-code a value:

  • Default (both networks): 95 SOL (HARDCAP_REQUIRED_LIQUIDITY = 95_000_000_000 lamports). Used when no per-token value is supplied at curve creation.
  • Bounds: MIN_REQUIRED_LIQUIDITY = 0.1 SOL, MAX_REQUIRED_LIQUIDITY = 10,000 SOL. Per-token values must fall within this range; the program rejects values outside it.
  • Devnet test mints are usually created with the maximum (10,000 SOL) to keep the curve from graduating mid-test. Read curve.required_liquidity per-mint instead of assuming a global value.

Graduation is automatic and atomic: the Control program seeds a Meteora pool with the accumulated lpEscrow SOL and the residual tokens in the curve's vault, then marks the curve completed. After graduation:

  • Buy / Sell on Control will revert (the curve sets is_completed = 1).
  • The token continues life as a regular Meteora DAMM v2 position.
  • Route subsequent trades through Meteora or any aggregator that supports it.
!

Detect graduation before each trade. A robust integration reads the curve account and checks is_completed; if the flag is 1, route via Meteora DAMM v2. The curve account stays on chain forever — it's frozen and drained at migration time, never closed — so don't code for a "missing curve" branch; that state never occurs. Also check is_frozen — the admin can pause trading on a curve.

10TypeScript Examples

End-to-end snippets for the four things every integrator needs: build a Buy transaction, build a Sell transaction, read the curve account for live quotes, and confirm what a trade actually delivered on-chain.

All snippets below use the legacy 1-byte discriminator (0x02 Buy, 0x03 Sell) for brevity — 17 bytes of instruction data total (1 disc + 8 sol_amount + 8 min_tokens_out). The on-chain program also accepts the 8-byte Anchor discriminator declared in the IDL — to use that form, replace the 1-byte writeUInt8 with the 8-byte Buffer.from([…]) at the start of the encoder; everything else (accounts, fields, behavior) is identical. Anchor SDK clients pick the 8-byte form automatically.

1. Build a Buy transaction

Derive every PDA / ATA from the mint, encode the instruction data, attach an idempotent ATA-creation instruction, and return an unsigned Transaction ready for the user's wallet to sign.

TypeScript
import {
  Connection,
  PublicKey,
  Transaction,
  TransactionInstruction,
  SystemProgram,
  Keypair,
  sendAndConfirmTransaction,
} from '@solana/web3.js';
import {
  TOKEN_2022_PROGRAM_ID,
  getAssociatedTokenAddressSync,
  createAssociatedTokenAccountIdempotentInstruction,
} from '@solana/spl-token';
import {
  CONTROL_PROGRAM_ID,
  deriveConfig,
  deriveCurve,
  deriveCommunityPool,
  deriveEventAuthority,
} from './pdas';
import { readCurveState, readProtocolFeeWallet } from './reads'; // from §10.3

const BUY_DISCRIMINANT = 2;

function encodeBuyData(solAmount: bigint, minTokensOut: bigint): Buffer {
  const buf = Buffer.alloc(1 + 8 + 8);
  buf.writeUInt8(BUY_DISCRIMINANT, 0);
  buf.writeBigUInt64LE(solAmount, 1);
  buf.writeBigUInt64LE(minTokensOut, 9);
  return buf;
}

export async function buildBuyTx(params: {
  connection: Connection;
  user: PublicKey;
  mint: PublicKey;
  solAmountLamports: bigint;
  minTokensOut: bigint;
  // Optional override — pass it if you've cached it (it's a global config value).
  protocolFeeWallet?: PublicKey;
}): Promise<Transaction> {
  const { connection, user, mint, solAmountLamports, minTokensOut } = params;

  // 1. Read on-chain state. The curve gives us vaultAta, lpEscrow, creatorFeeVault
  //    pre-derived; the config gives us the protocol fee wallet (cache it).
  const curveState = await readCurveState(connection, mint);
  if (!curveState) throw new Error('Curve PDA not found — wrong cluster, or this mint was not launched on Control');
  if (curveState.isCompleted) throw new Error('Curve graduated — route via Meteora DAMM v2');
  if (curveState.isFrozen)    throw new Error('Curve frozen mid-migration — retry shortly');
  const protocolFeeWallet = params.protocolFeeWallet ?? await readProtocolFeeWallet(connection);

  // 2. Compute the remaining accounts (PDAs derived from the mint, plus the user ATA).
  const config = deriveConfig();
  const curve = deriveCurve(mint);
  const communityPool = deriveCommunityPool(mint);
  const eventAuthority = deriveEventAuthority();
  const userAta = getAssociatedTokenAddressSync(
    mint, user, false, TOKEN_2022_PROGRAM_ID,
  );

  // 3. Idempotent ATA creation for the user (Token-2022).
  const ataIx = createAssociatedTokenAccountIdempotentInstruction(
    user, userAta, user, mint, TOKEN_2022_PROGRAM_ID,
  );

  // 4. Build the Buy instruction (14 accounts — slots 12 and 13 are the self-CPI TradeEvent accounts).
  const buyIx = new TransactionInstruction({
    programId: CONTROL_PROGRAM_ID,
    keys: [
      { pubkey: user,                       isSigner: true,  isWritable: true  },
      { pubkey: config,                     isSigner: false, isWritable: false },
      { pubkey: curve,                      isSigner: false, isWritable: true  },
      { pubkey: mint,                       isSigner: false, isWritable: false },
      { pubkey: curveState.vaultAta,        isSigner: false, isWritable: true  },
      { pubkey: userAta,                    isSigner: false, isWritable: true  },
      { pubkey: curveState.creatorFeeVault, isSigner: false, isWritable: true  },
      { pubkey: protocolFeeWallet,          isSigner: false, isWritable: true  },
      { pubkey: curveState.lpEscrow,        isSigner: false, isWritable: true  },
      { pubkey: communityPool,              isSigner: false, isWritable: true  },
      { pubkey: TOKEN_2022_PROGRAM_ID,      isSigner: false, isWritable: false },
      { pubkey: SystemProgram.programId,    isSigner: false, isWritable: false },
      { pubkey: eventAuthority,             isSigner: false, isWritable: false },
      { pubkey: CONTROL_PROGRAM_ID,         isSigner: false, isWritable: false },
    ],
    data: encodeBuyData(solAmountLamports, minTokensOut),
  });

  const tx = new Transaction().add(ataIx, buyIx);
  tx.feePayer = user;
  tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
  return tx;
}

// Usage — signer is the end user's wallet (Phantom, Backpack, etc.).
// For Node, use a Keypair:
async function example() {
  const connection = new Connection('https://api.devnet.solana.com', 'confirmed');
  const user = Keypair.generate(); // replace with wallet keypair

  const tx = await buildBuyTx({
    connection,
    user: user.publicKey,
    mint: new PublicKey('<CONTROL_TOKEN_MINT>'),
    solAmountLamports: 100_000_000n, // 0.1 SOL
    minTokensOut: 1n,                  // set based on quoteBuy(state, in) + your slippage tolerance
    // protocolFeeWallet: cachedProtocolFeeWallet, // optional: pass cached value to skip the config read
  });

  const sig = await sendAndConfirmTransaction(connection, tx, [user]);
  console.log('Buy landed:', sig);

  // Verify the resulting balance. NOTE: pass TOKEN_2022_PROGRAM_ID as the 4th
  // arg of getAccount — Control mints are Token-2022, the SPL-token default
  // SPL Token program ID will return AccountInvalidOwner / wrong-program errors.
  const { getAccount } = await import('@solana/spl-token');
  const userAta = getAssociatedTokenAddressSync(
    new PublicKey('<CONTROL_TOKEN_MINT>'), user.publicKey, false, TOKEN_2022_PROGRAM_ID,
  );
  const ataState = await getAccount(
    connection, userAta, 'confirmed', TOKEN_2022_PROGRAM_ID, // ← critical 4th arg
  );
  console.log('Tokens received:', ataState.amount.toString());
}

2. Build a Sell transaction

Sell is symmetric to Buy. Discriminant flips to 0x03, sol_amount/min_tokens_out become token_amount/min_sol_out, and the account list drops system_program (13 accounts vs Buy's 14). The eventAuthority + program self-CPI accounts move from slots 12/13 (Buy) to slots 11/12 (Sell) but otherwise carry the same values. The user's ATA must already exist and hold the tokens being sold.

TypeScript
import {
  Connection,
  PublicKey,
  Transaction,
  TransactionInstruction,
} from '@solana/web3.js';
import {
  TOKEN_2022_PROGRAM_ID,
  getAssociatedTokenAddressSync,
} from '@solana/spl-token';
import {
  CONTROL_PROGRAM_ID,
  deriveConfig,
  deriveCurve,
  deriveCommunityPool,
  deriveEventAuthority,
} from './pdas';
import { readCurveState, readProtocolFeeWallet } from './reads'; // from §10.3

const SELL_DISCRIMINANT = 3;

function encodeSellData(tokenAmount: bigint, minSolOut: bigint): Buffer {
  const buf = Buffer.alloc(1 + 8 + 8);
  buf.writeUInt8(SELL_DISCRIMINANT, 0);
  buf.writeBigUInt64LE(tokenAmount, 1);
  buf.writeBigUInt64LE(minSolOut, 9);
  return buf;
}

export async function buildSellTx(params: {
  connection: Connection;
  user: PublicKey;
  mint: PublicKey;
  tokenAmount: bigint;     // raw base units (respects mint decimals)
  minSolOut: bigint;       // lamports, slippage-protected floor
  // Optional override — pass it if you've cached it (it's a global config value).
  protocolFeeWallet?: PublicKey;
}): Promise<Transaction> {
  const { connection, user, mint, tokenAmount, minSolOut } = params;

  // 1. Read on-chain state — same pattern as Buy.
  const curveState = await readCurveState(connection, mint);
  if (!curveState) throw new Error('Curve PDA not found — wrong cluster, or this mint was not launched on Control');
  if (curveState.isCompleted) throw new Error('Curve graduated — route via Meteora DAMM v2');
  if (curveState.isFrozen)    throw new Error('Curve frozen mid-migration — retry shortly');
  const protocolFeeWallet = params.protocolFeeWallet ?? await readProtocolFeeWallet(connection);

  // 2. Compute the remaining accounts.
  const config = deriveConfig();
  const curve = deriveCurve(mint);
  const communityPool = deriveCommunityPool(mint);
  const eventAuthority = deriveEventAuthority();
  const userAta = getAssociatedTokenAddressSync(
    mint, user, false, TOKEN_2022_PROGRAM_ID,
  );

  // 3. Build the Sell instruction (13 accounts — Buy's first 10 + token2022 +
  //    eventAuthority + program. system_program is omitted; self-CPI accounts
  //    sit at slots 11 and 12 instead of 12 and 13).
  const sellIx = new TransactionInstruction({
    programId: CONTROL_PROGRAM_ID,
    keys: [
      { pubkey: user,                       isSigner: true,  isWritable: true  },
      { pubkey: config,                     isSigner: false, isWritable: false },
      { pubkey: curve,                      isSigner: false, isWritable: true  },
      { pubkey: mint,                       isSigner: false, isWritable: false },
      { pubkey: curveState.vaultAta,        isSigner: false, isWritable: true  },
      { pubkey: userAta,                    isSigner: false, isWritable: true  },
      { pubkey: curveState.creatorFeeVault, isSigner: false, isWritable: true  },
      { pubkey: protocolFeeWallet,          isSigner: false, isWritable: true  },
      { pubkey: curveState.lpEscrow,        isSigner: false, isWritable: true  },
      { pubkey: communityPool,              isSigner: false, isWritable: true  },
      { pubkey: TOKEN_2022_PROGRAM_ID,      isSigner: false, isWritable: false },
      { pubkey: eventAuthority,             isSigner: false, isWritable: false },
      { pubkey: CONTROL_PROGRAM_ID,         isSigner: false, isWritable: false },
    ],
    data: encodeSellData(tokenAmount, minSolOut),
  });

  const tx = new Transaction().add(sellIx);
  tx.feePayer = user;
  tx.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
  return tx;
}
!

Do not call Sell after graduation. Once the curve migrates to Meteora DAMM v2 the program freezes and drains the curve, sets is_completed = 1, and reverts any further Buy/Sell. The curve account itself stays on chain — it is never closed — so detect graduation via is_completed === 1 on the curve account and route through Meteora DAMM v2 instead.

3. Read on-chain state (curve + config)

Two account reads cover everything you need to build a Buy or Sell from just the mint pubkey. The curve account (344 bytes, repr(C, packed)) holds the per-mint reserves, fee addon, and the four addresses you'd otherwise have to derive yourself (vaultAta, lpEscrow, creatorFeeVault, creator). The config account (80 bytes) holds the global protocolFeeWallet. Read the curve before every quote (virtual reserves are constant, real reserves shift on every trade); cache the config — it doesn't change.

TypeScript
import { Connection, PublicKey } from '@solana/web3.js';
import { deriveCurve, deriveConfig } from './pdas';

// Field offsets inside the 344-byte ControlCurve account (repr(C, packed)).
const OFFSET_MINT                     = 0;     // pubkey (32) — the token mint this curve trades
const OFFSET_AUTHORITY                = 32;    // pubkey (32) — curve authority (admin); rarely needed by integrators
const OFFSET_VIRTUAL_SOL              = 64;
const OFFSET_VIRTUAL_TOKEN            = 72;
const OFFSET_REAL_SOL                 = 80;
const OFFSET_REAL_TOKEN               = 88;
const OFFSET_INITIAL_TOKEN_RESERVE    = 96;
const OFFSET_TOTAL_SUPPLY             = 104;
const OFFSET_REQUIRED_LIQ             = 112;
const OFFSET_IS_COMPLETED             = 120;
const OFFSET_BUMP                     = 121;   // curve PDA bump (u8)
const OFFSET_IS_FROZEN                = 122;
// 123..144: pad + created_at + max_user_tokens — not exposed below.
const OFFSET_VAULT_ATA                = 144;   // pubkey (32) — pre-derived vaultAta, you can use this directly
const OFFSET_CREATOR                  = 176;   // pubkey (32) — token creator wallet (NOT the same as authority)
const OFFSET_LP_ESCROW                = 208;   // pubkey (32) — pre-derived lpEscrow PDA
const OFFSET_EXTRA_COMMUNITY_FEE_BPS  = 240;   // u16 LE — addon to community_fee_bps (in addition to the 1.00% base)
// 242..312: pad + meteora_pool + position_nft_mint — only set after graduation.
const OFFSET_CREATOR_FEE_VAULT        = 312;   // pubkey (32) — pre-derived creatorFeeVault PDA

export interface CurveState {
  mint: PublicKey;
  creator: PublicKey;             // token creator (use to derive creatorFeeVault — or read OFFSET_CREATOR_FEE_VAULT directly)
  vaultAta: PublicKey;             // pre-derived
  lpEscrow: PublicKey;             // pre-derived
  creatorFeeVault: PublicKey;      // pre-derived — saves a findProgramAddressSync call
  bump: number;
  virtualSolReserve: bigint;
  virtualTokenReserve: bigint;
  realSolReserve: bigint;
  realTokenReserve: bigint;
  initialTokenReserve: bigint;
  totalSupply: bigint;
  requiredLiquidity: bigint;       // graduation threshold (lamports)
  extraCommunityFeeBps: number;    // per-mint addon over the base community fee
  isCompleted: boolean;            // true once migrated to Meteora DAMM v2
  isFrozen: boolean;               // true while migration is in flight
}

export async function readCurveState(
  connection: Connection,
  mint: PublicKey,
): Promise<CurveState | null> {
  const info = await connection.getAccountInfo(deriveCurve(mint));
  if (!info) return null; // account doesn't exist (wrong cluster or mint never launched on Control) — not a graduation signal; the curve PDA stays on chain even after graduation

  const d = info.data;
  return {
    mint:                  new PublicKey(d.slice(OFFSET_MINT, OFFSET_MINT + 32)),
    creator:               new PublicKey(d.slice(OFFSET_CREATOR, OFFSET_CREATOR + 32)),
    vaultAta:              new PublicKey(d.slice(OFFSET_VAULT_ATA, OFFSET_VAULT_ATA + 32)),
    lpEscrow:              new PublicKey(d.slice(OFFSET_LP_ESCROW, OFFSET_LP_ESCROW + 32)),
    creatorFeeVault:       new PublicKey(d.slice(OFFSET_CREATOR_FEE_VAULT, OFFSET_CREATOR_FEE_VAULT + 32)),
    bump:                  d[OFFSET_BUMP],
    virtualSolReserve:     d.readBigUInt64LE(OFFSET_VIRTUAL_SOL),
    virtualTokenReserve:   d.readBigUInt64LE(OFFSET_VIRTUAL_TOKEN),
    realSolReserve:        d.readBigUInt64LE(OFFSET_REAL_SOL),
    realTokenReserve:      d.readBigUInt64LE(OFFSET_REAL_TOKEN),
    initialTokenReserve:   d.readBigUInt64LE(OFFSET_INITIAL_TOKEN_RESERVE),
    totalSupply:           d.readBigUInt64LE(OFFSET_TOTAL_SUPPLY),
    requiredLiquidity:     d.readBigUInt64LE(OFFSET_REQUIRED_LIQ),
    extraCommunityFeeBps:  d.readUInt16LE(OFFSET_EXTRA_COMMUNITY_FEE_BPS),
    isCompleted: d[OFFSET_IS_COMPLETED] === 1,
    isFrozen:    d[OFFSET_IS_FROZEN]    === 1,
  };
}

// Total trading fee bps (always taken from the input side):
// creator(15) + protocol(60) + lp(25) + community_base(100) + extra_community(per-mint)
// = 200 bps fixed + extra_community_fee_bps from the curve. Default mints carry
// extra_community = 100 → 300 bps total = 3.00%, but per-mint values vary.
const FIXED_FEE_BPS = 200n;    // 15 + 60 + 25 + 100 (base community)
const BPS_DENOM     = 10_000n;

function totalFeeBps(state: CurveState): bigint {
  return FIXED_FEE_BPS + BigInt(state.extraCommunityFeeBps);
}

export function quoteBuy(state: CurveState, solInLamports: bigint): bigint {
  const totalSol   = state.virtualSolReserve   + state.realSolReserve;
  const totalToken = state.virtualTokenReserve + state.realTokenReserve;
  const fee   = (solInLamports * totalFeeBps(state)) / BPS_DENOM;
  const netIn = solInLamports - fee;
  return (totalToken * netIn) / (totalSol + netIn);
}

export function quoteSell(state: CurveState, tokenIn: bigint): bigint {
  const totalSol   = state.virtualSolReserve   + state.realSolReserve;
  const totalToken = state.virtualTokenReserve + state.realTokenReserve;
  const grossOut = (totalSol * tokenIn) / (totalToken + tokenIn);
  return grossOut - (grossOut * totalFeeBps(state)) / BPS_DENOM;
}

// ---------- Read the config account ----------
// ControlConfig layout (80 bytes, repr(C, packed)):
//   0..32   admin                (pubkey)
//   32..64  protocol_fee_wallet  (pubkey)   ← what integrators need for Buy/Sell
//   64..66  creator_fee_bps      (u16 LE)   = 15  → 0.15%
//   66..68  protocol_fee_bps     (u16 LE)   = 60  → 0.60%
//   68..70  lp_fee_bps           (u16 LE)   = 25  → 0.25%
//   70..72  community_fee_bps    (u16 LE)   = 100 → 1.00%
//   72..80  create_fee           (u64 LE)
const OFFSET_PROTOCOL_FEE_WALLET = 32;

export async function readProtocolFeeWallet(
  connection: Connection,
): Promise<PublicKey> {
  const info = await connection.getAccountInfo(deriveConfig());
  if (!info) throw new Error('Control config PDA not found — wrong cluster?');
  return new PublicKey(info.data.slice(OFFSET_PROTOCOL_FEE_WALLET, OFFSET_PROTOCOL_FEE_WALLET + 32));
}
i

Per-token fees. Total trading fee is 200 bps + curve.extra_community_fee_bps taken from the input. The fixed 200 bps is the sum of creator (15) + protocol (60) + LP (25) + community_base (100). Default-config mints carry extra_community_fee_bps = 100 for a clean 3.00% total, but per-mint values vary — always read the field via readCurveState() at quote time. The decoder above already does this for you. The four pre-derived PDAs (vaultAta, lpEscrow, creatorFeeVault) and the creator pubkey are also exposed in the same struct, so building a Buy/Sell tx never requires more than one curve-account read.

4. Decode the on-chain TradeEvent

Every Buy and Sell emits an Anchor self-CPI TradeEvent as an inner instruction on the program. The event payload is a 153-byte buffer: 8-byte Anchor self-CPI prefix + 8-byte TradeEvent discriminator + 12 fields. Reading it gives you the exact swap result (solAmount, tokenAmount, fees, post-trade reserves) without touching balance deltas.

Buffer layout (153 bytes)
0..8     ANCHOR_LOG_DISC      sha256("anchor:event")[..8]   = [228, 69,165, 46, 81,203,154, 29]
8..16    TRADE_EVENT_DISC     sha256("event:TradeEvent")[..8] = [189,219,127,211, 78,230, 97,238]
16..48   mint                 pubkey (32)
48..56   solAmount            u64 LE  Buy: post-fee net into AMM. Sell: pre-fee gross out of AMM. See callout below.
56..64   tokenAmount          u64 LE (raw base units moved)
64..65   isBuy                u8     (1 = Buy, 0 = Sell)
65..97   user                 pubkey (32)
97..105  virtualSolReserves   u64 LE (post-trade)
105..113 virtualTokenReserves u64 LE (post-trade)
113..121 realSolReserves      u64 LE (post-trade)
121..129 realTokenReserves    u64 LE (post-trade)
129..137 fee                  u64 LE (total fee taken from the trade)
137..145 creatorFee           u64 LE (creator's slice of `fee`)
145..153 solToUser            u64 LE (Sell only — net SOL credited to the seller)
!

solAmount means different things on Buy vs Sell. The instruction's sol_amount arg is what the user supplies as input; the event's solAmount field is what entered or left the AMM curve after fee accounting. They are NOT the same:

  • Buy: event.solAmount = arg.sol_amount − event.fee (post-fee, what actually entered the curve). On a 0.01 SOL Buy with default 3.00% fee: arg = 10_000_000, event.fee = 300_000, event.solAmount = 9_700_000.
  • Sell: event.solAmount is the gross AMM output before fees are taken. The user actually receives event.solToUser = event.solAmount − event.fee. On a Sell that produces 0.0048 SOL gross at default 3% fee: event.solAmount = 4_850_001, event.fee = 145_500, event.solToUser = 4_704_501 (the wallet credit).

Reconcile slippage UX against event.solAmount for Buy and event.solToUser for Sell — both reflect what the user experienced, neither equals the instruction arg directly.

TypeScript
// Tested with @solana/web3.js@1.x, @solana/spl-token@0.4.x, bs58@5+ (default-export style).
// On bs58@4 swap to: import * as bs58 from 'bs58';
import { Connection, PublicKey } from '@solana/web3.js';
import bs58 from 'bs58';
import { CONTROL_PROGRAM_ID } from './pdas';

// sha256("anchor:event")[..8] — every Anchor self-CPI log starts with this.
const ANCHOR_LOG_DISC = Buffer.from([228, 69, 165, 46, 81, 203, 154, 29]);
// sha256("event:TradeEvent")[..8] — TradeEvent's per-event discriminator.
const TRADE_EVENT_DISC = Buffer.from([189, 219, 127, 211, 78, 230, 97, 238]);
const TRADE_EVENT_LEN = 153;

export interface TradeEvent {
  mint: PublicKey;
  user: PublicKey;
  isBuy: boolean;
  solAmount: bigint;
  tokenAmount: bigint;
  fee: bigint;
  creatorFee: bigint;
  solToUser: bigint;
  virtualSolReserves: bigint;
  virtualTokenReserves: bigint;
  realSolReserves: bigint;
  realTokenReserves: bigint;
}

function decodeTradeEvent(buf: Buffer): TradeEvent {
  if (buf.length !== TRADE_EVENT_LEN) throw new Error(`bad event length: ${buf.length}`);
  if (!buf.slice(0, 8).equals(ANCHOR_LOG_DISC))   throw new Error('not an anchor self-CPI log');
  if (!buf.slice(8, 16).equals(TRADE_EVENT_DISC)) throw new Error('not a TradeEvent');

  return {
    mint:                 new PublicKey(buf.slice(16, 48)),
    solAmount:            buf.readBigUInt64LE(48),
    tokenAmount:          buf.readBigUInt64LE(56),
    isBuy:                buf[64] === 1,
    user:                 new PublicKey(buf.slice(65, 97)),
    virtualSolReserves:   buf.readBigUInt64LE(97),
    virtualTokenReserves: buf.readBigUInt64LE(105),
    realSolReserves:      buf.readBigUInt64LE(113),
    realTokenReserves:    buf.readBigUInt64LE(121),
    fee:                  buf.readBigUInt64LE(129),
    creatorFee:           buf.readBigUInt64LE(137),
    solToUser:            buf.readBigUInt64LE(145),
  };
}

// Walk the inner instructions emitted by the Control program and return
// every TradeEvent. Most txs have exactly one; batched ones may have more.
export async function parseTradeEvents(
  connection: Connection,
  signature: string,
): Promise<TradeEvent[]> {
  const tx = await connection.getTransaction(signature, {
    commitment: 'confirmed',
    maxSupportedTransactionVersion: 0,
  });
  if (!tx || !tx.meta) throw new Error('Transaction not found or missing meta');
  if (tx.meta.err)    throw new Error(`Transaction failed: ${JSON.stringify(tx.meta.err)}`);

  const staticKeys = tx.transaction.message.getAccountKeys().staticAccountKeys;
  const programIdx = staticKeys.findIndex((k) => k.equals(CONTROL_PROGRAM_ID));
  if (programIdx < 0) return [];

  const events: TradeEvent[] = [];
  for (const innerSet of tx.meta.innerInstructions ?? []) {
    for (const ix of innerSet.instructions) {
      if (ix.programIdIndex !== programIdx) continue;
      const data = Buffer.from(bs58.decode(ix.data));
      if (data.length !== TRADE_EVENT_LEN) continue;
      if (!data.slice(0, 8).equals(ANCHOR_LOG_DISC)) continue;
      events.push(decodeTradeEvent(data));
    }
  }
  return events;
}
i

Why this matters. Solscan and SolanaFM render trade activity from this same event (same Anchor self-CPI convention as Anchor programs), which is why your txs show "Action: Swap N for M SOL" out of the box. Indexers can key on the program ID + the 16-byte prefix (ANCHOR_LOG_DISC + TRADE_EVENT_DISC) to detect every Buy/Sell across all Control mints with no per-mint subscription.

11Reference Transactions

Real on-chain Create, Buy, and Sell transactions on Devnet and Mainnet — use them to validate your decoder, copy account orderings, and reproduce the wire format end to end.

i

Current as of May 2026. Both Mainnet and Devnet are running the self-CPI + dual-mode-discriminator program (Buy = 14 accounts, Sell = 13, every trade emits a TradeEvent as an inner instruction). The signatures below were all produced with the 8-byte Anchor disc — the legacy 0x02/0x03 path is still accepted by the dispatcher. Both networks share the same Program ID and the same wire format, so the same client code works against either with only the cluster URL changed.

The Discriminant column below shows the legacy 1-byte form for quick scanning. Every sample transaction linked here was actually signed with the 8-byte Anchor form (sha256("global:<name>")[..8] — see §05/§06 for the full byte arrays), so if you decode the raw instruction data you'll see the 8-byte prefix, not the byte in this column. Both forms work on-chain; the column is for human reference.

Devnet

Instruction Discriminant Signature (Solscan)
Create 0x09 2b3z7kX2…6zTb
Buy 0x02 3mW4Tryy…sqsZCs
Sell 0x03 3FX69Lid…YRgK

Mainnet

!

Mainnet rows below are pre-redeploy. The Devnet sample sigs above are from the post-May-2026 contract update that pre-allocates the community-pool PDA at CreateToken time (see "Parsing the Create transaction" below — Create is now 13 accounts, was 12). The Mainnet contract redeploy with the same change is rolling out next; until it lands, the Mainnet sample sigs in the table reflect the prior 12-account CreateToken layout. Buy and Sell are unchanged on both networks (still 14 / 13 accounts).

Instruction Discriminant Signature (Solscan)
Create 0x09 5aMjqtnm…dUYAe
Buy 0x02 2yBpVSUq…6iX4
Sell 0x03 2bEG5cx9…humAKQ

Parsing the Create transaction (parse-only)

!

Integrators do not call CreateToken. This instruction is restricted to the Control admin co-signer; tokens can only be minted through the Control platform. There is no public path to invoke it, and that will not change. This section exists so indexers, listing pipelines, and discovery bots can parse Create transactions to detect new launches in real time — never to construct one.

CreateToken uses discriminant 0x09 (legacy 1-byte) / [84, 52, 204, 228, 24, 140, 234, 75] (8-byte Anchor sighash, what the IDL declares and what every shipped client sends) and 13 accounts. The mint is a one-shot keypair signer (it signs only this transaction); after Create the curve is fully bootstrapped and the very next instruction at the same mint will be a Buy. To extract the new mint and its creator from a Create tx, read the signed accounts and the program's instruction data:

Account layout (13 accounts, post May 2026 update)

# Account Role Notes
0mintwsThe new Token-2022 mint. Signs only this tx (one-shot keypair).
1payerwsToken creator; pays rent + create-fee + community-pool rent.
2adminrsControl admin co-signer. Required — gates the instruction.
3configPDArControl config PDA.
4curvePDAwControl curve PDA, initialized in this tx.
5protocolFeeWalletwReceives the create-fee.
6vaultAtawCurve vault token account (Token-2022).
7lpEscrowPDAwLP escrow PDA seeded for this mint.
8creatorFeeVaultPDAwPer-mint creator fee vault PDA.
9communityPoolPDAwPer-token community pool PDA, pre-allocated rent-exempt at CreateToken time. Added May 2026 so first trades smaller than ~0.05 SOL no longer fail with insufficient funds for rent — the prior implicit floor is gone.
10systemProgramrSystem program. (Was slot 9 before May 2026.)
11token2022ProgramrToken-2022 program. (Was slot 10.)
12ataProgramrAssociated Token Program. (Was slot 11.)

Slots 0 (mint), 1 (payer / creator), and 4 (curve PDA) are unchanged from the pre-update layout — any indexer that only reads those positions to detect new launches keeps working without changes.

i

What you typically extract. Account 0 is the new mint pubkey, account 1 is the creator (token launcher) wallet, account 4 is the curve PDA you'll start watching for trades. The instruction data carries the metadata args (name, symbol, URI) used to seed the Token-2022 metadata extension — refer to a Solscan-decoded sample tx (links above) to see the exact byte layout in context, and re-pull a fresh tx once the CPI-logs upgrade ships for a structured representation.

Detecting new tokens in real time

Use Solana logs subscription or transaction subscription on the Control program ID (CTRL5CCEQw5zhhBeEV8n5GKZpf3E5tYQoXhhxzUAps27) and match CreateToken on either of the two accepted discriminator forms:

  • 8-byte Anchor sighash (current default — what every shipped client sends; what the IDL declares; what Solscan / SolanaFM / Anchor SDK match on): data[0..8] == [84, 52, 204, 228, 24, 140, 234, 75] (= sha256("global:create_token")[..8]; the leading byte is 0x54, NOT 0x09).
  • Legacy 1-byte enum disc (still accepted by the dispatcher for backward compatibility): data[0] === 0x09.

A robust matcher checks both: indexers that filter only on data[0] == 0x09 will silently miss every CreateToken submitted by Control's official services, the test scripts, or any Anchor SDK client — those all use the 8-byte form. With either match, account 0 is the new mint pubkey, account 1 is the creator, account 4 is the curve PDA — point the parsers from the TypeScript Examples section at the new mint and you're ready to quote, buy, or sell.

12IDL & Roadmap

The on-chain IDL (Anchor 0.30+ format) and the full inventory of program instructions.

Control is built on Pinocchio, not Anchor — but the program publishes an Anchor 0.30+ format IDL on-chain so that explorers and indexers (Solscan, SolanaFM, Solana Explorer) can decode the self-CPI TradeEvent into a "Swap N for M SOL" UI out of the box. Each instruction has an 8-byte discriminator array and the IDL exposes the events + types blocks Anchor parsers expect. Fetch it from the Solana Explorer:

explorer.solana.com/address/CTRL5CCEQw5zhhBeEV8n5GKZpf3E5tYQoXhhxzUAps27/idl

i

Discriminators are 8-byte arrays now. Each instruction in the IDL has a discriminator: [u8; 8] (sha256("global:<name>")[..8] — Anchor 0.30+ convention). The on-chain dispatcher accepts both forms: the Anchor 8-byte prefix or the legacy 1-byte enum byte. New clients should prefer the 8-byte form (it's what Solscan/SolanaFM decode against and what the Anchor SDK auto-generates), but the 1-byte path is preserved indefinitely for backwards compatibility.

i

Args blocks are now populated. Pinocchio parses instruction data manually with bytemuck (no derive macro), so a raw shank IDL cannot introspect arg layouts. The published IDL solves this with a post-processor (scripts/augment_idl.ts) that hand-rolls the args for each instruction so explorers can decode them. buy declares sol_amount: u64, min_tokens_out: u64; sell declares amount: u64, min_sol_out: u64 (the IDL uses amount as the field name; this guide writes it as token_amount in prose because it's the more descriptive label — same wire bytes either way).

Full instruction inventory (out of scope for Buy/Sell integrators)

The Control program ships 40 instructions (discriminants 0–39). Buy (0x02) and Sell (0x03) are documented in detail above; everything else is admin, migration, or post-migration logic that integrators do not need to call. This table accounts for every remaining discriminant so you know what the program does, even though none of it affects the Buy/Sell flow.

Feature Discriminants Notes
Token & curve creation 0, 1, 9 InitializeConfig, InitializeCurve, CreateToken. Admin/creator only — mints the Token-2022 mint and bootstraps the curve. Integrators consume the resulting mint, never call these.
Freeze / thaw & config updates 5–8, 10, 17, 18, 34, 36 AdminFreeze/AdminThaw, FreezeCurve/ThawCurve, UpdateConfig, UpdateSplits, UpdateWallets, MigrateConfig, UpdateLpMinThreshold. Restricted to config.admin.
Migration to Meteora DAMM v2 11, 12, 13 MigrateToMeteora, WithdrawLpEscrow, TransferNftToFeeConfig. Triggered automatically when the curve hits required_liquidity. Detect graduation client-side via curve.is_completed === 1 and route through Meteora DAMM v2 from that point on. (The curve account itself is never closed — it's frozen and drained but stays on chain — so the is_completed flag is the only signal you need.)
Post-migration LP & fee distribution 4, 15, 16, 19 WithdrawLiquidity, InitializeFeeConfig, ClaimAndDistribute, GrowLiquidity. Manage the graduated Meteora position and distribute accumulated fees across the 25-tier market-cap split table.
Fee claims & admin withdrawals 14, 20, 21, 22, 30, 31 ClaimCreatorFees, plus the AdminWithdraw* family for creator fee vault, fee config, generic PDA, community pool, and engine SOL. Creator and admin only.
Buyback Engine 23–29 (0x17–0x1D) CreateBuybackEngine, FundEngine, EngineBuyCurve, EngineBuyMeteora, EngineDistribute, ClaimUserTokens, CloseEngine. Post-migration buy-and-distribute pipeline that recycles community-pool SOL into supply held for token holders. Protocol-driven.
Daily Jackpot 32, 33, 35 (0x20, 0x21, 0x23) CreateDailyJackpot, SweepToJackpot, RequestJackpotDraw. Provably-fair daily prize draw using ORAO VRF randomness. Admin-managed; not part of trading.
On-chain IDL management 37, 38, 39 IdlCreate, IdlWrite, IdlClose. Allocates the Anchor-style IdlAccount at create_with_seed(program_signer, "anchor:idl", program_id), writes the zlib-compressed IDL in chunks, and reclaims the rent on close. Run by contract_deployments/update-idl-prod.sh alongside the @solana-program/program-metadata path so explorers reading either store find the IDL. Admin only — integrators never call these.

Planned additions (post-v1)

Item What it unlocks
REST metadata endpoints List of live mints, creator info, enriched token metadata. Public (no API key).
WebSocket new_token and graduation streams for real-time listeners.
Hosted /buildTx Server-side transaction construction so clients skip PDA derivation entirely.

13Contact

Direct line to the Control team for integration questions.

Integration lead Carlos Beltrán — cb@print.world
Program ID CTRL5CCEQw5zhhBeEV8n5GKZpf3E5tYQoXhhxzUAps27
Networks Mainnet + Devnet — same program ID on both
Test mints Devnet (canonical, 10,000 SOL graduation threshold so it won't graduate mid-test): 6XyiLwNWGwkuWunt2XX7uiMfJJDgw8wdeowVnPxmizRe.
Devnet (legacy, used for the §11 Devnet sample sigs): 8poC3bFzLNZPuzvSRNiuEDACsW5Ymtw7HNKWX55JgavW.
Mainnet (1,000 SOL graduation threshold): 2awKV3D3r8T6jLKsyHTzjsG2hMtNbsW2vL4gfGsTVXsw — also the mint used for the Mainnet sample transactions in §11 Reference Transactions.

These mints are provisioned specifically so external integrators can run extensive tests without the curve graduating mid-session. They were created with non-default required_liquidity (1,000 / 10,000 SOL instead of the standard 95 SOL) and a correspondingly larger virtual_sol_reserve. Tokens launched on Control in real production will use the standard defaults (95 SOL threshold and the matching virtual reserves) — your client code should always read these values per-mint from the curve account (§10.3) and not hardcode any number from this section.

If you're integrating a listing, aggregator, or trading bot and need a test-mint with known reserves, reach out and we'll provision one on devnet.