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.
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.
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 + Devnet — same 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 |
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
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.
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.
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]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
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]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 |
|---|---|---|
| 0 | user | signerw |
| 1 | config | PDAr |
| 2 | curve | PDAw |
| 3 | mint | r |
| 4 | vaultAta | ATAw |
| 5 | userAta | ATAw |
| 6 | creatorFeeVault | PDAw |
| 7 | protocolFeeWallet | w |
| 8 | lpEscrow | PDAw |
| 9 | communityPool | PDAw |
| 10 | token2022Program | r |
| 11 | eventAuthority | PDAr |
| 12 | program | r |
Instruction data layout
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.
// 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 |
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:
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_000lamports). 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. Readcurve.required_liquidityper-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.
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.
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.
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)); }
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.
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.solAmountis the gross AMM output before fees are taken. The user actually receivesevent.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.
// 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; }
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.
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 |
|---|---|---|---|
| 0 | mint | ws | The new Token-2022 mint. Signs only this tx (one-shot keypair). |
| 1 | payer | ws | Token creator; pays rent + create-fee + community-pool rent. |
| 2 | admin | rs | Control admin co-signer. Required — gates the instruction. |
| 3 | config | PDAr | Control config PDA. |
| 4 | curve | PDAw | Control curve PDA, initialized in this tx. |
| 5 | protocolFeeWallet | w | Receives the create-fee. |
| 6 | vaultAta | w | Curve vault token account (Token-2022). |
| 7 | lpEscrow | PDAw | LP escrow PDA seeded for this mint. |
| 8 | creatorFeeVault | PDAw | Per-mint creator fee vault PDA. |
| 9 | communityPool | PDAw | Per-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. |
| 10 | systemProgram | r | System program. (Was slot 9 before May 2026.) |
| 11 | token2022Program | r | Token-2022 program. (Was slot 10.) |
| 12 | ataProgram | r | Associated 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.
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 is0x54, NOT0x09). -
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
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.
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
|
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.