Files
pumpfun-bonkfun-bot_github/cookbook/pumpswap/pumpswap_sell_token.py
T
ae937fd477 fix(cookbook,tools): fail on an unreadable value instead of substituting a default (#225)
* fix(cookbook,tools): read GEYSER_AUTH_TYPE and reject an unknown scheme

Four Geyser clients advertised the variable and ignored it, sending Basic for
anything that was not exactly "x-token". Validate against {x-token, basic}.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): detect blockSubscribe creations from logs alone

The envelope fallback cannot see a create reached by CPI, so it covered a
subset of the log route. Envelope decoding stays in the decode/ script.
Also skip meta.err: logs emitted before a failure are kept, and that mint
does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): raise when the graduation baseline cannot be read

The watchers fell back to a hardcoded 793,100,000 and still printed it as
"(from Global)". Every progress figure is measured against that baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): report an unsent create flag as unknown, not false

Truncated create_v2 args and legacy create were filled in as False, reading
as a coin the program said is not in mayhem mode. Leave them out, print N/A.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tools): build Geyser credentials before the reconnect loop

The loop catches every exception and sleeps, so an unusable auth type
reconnected forever instead of reporting a misconfiguration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): treat a short Global buffer as an unreadable baseline

The guard only caught a missing account, so a short read raised struct.error
past the documented ValueError.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tools): surface a listener's exception instead of an idle market

A listener that raised on connect returned nothing, exactly like a quiet
market, in the harness whose job is to certify listeners. Take the listener
list from the factory too, so one added there is not silently skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tools): simulate the transaction the bot built

The hook rebuilt the transaction itself and dropped the account data size
limit, so the simulation was more permissive than the send. Hook the RPC
client instead, build the client before detection so its blockhash cache is
warm, and report the compute budget read back off the wire.

A listener that raised on connect also read as a quiet market here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(tools): skip failed migrations on err, not on the word "Error"

The notification carries err beside the logs. Scanning the text dropped a
migration whose logs merely said "Error" and kept ones that truly failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): refuse a letsbonk buy that accepts any output

The only one of the four without the guard. Near graduation the floor
division reaches zero, and the buy submits with no slippage protection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): require the pool's own platform_config

The fallback was a partner-specific address that reverts with
ConstraintAddress (2012). A pool that omits it cannot be traded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): raise when curve data ends before a flag

is_mayhem_mode picks the fee recipient for a buy, and the wrong one reverts
with NotAuthorized (6000), so defaulting it only moves the failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cookbook): trade the coin's own PumpSwap pool, not an arbitrary one

Anyone can open a pool against any mint and getProgramAccounts has no defined
order, so [0] was a stranger's pool. Select on coin_creator, which migration
copies from the bonding curve and nothing else sets, and refuse rather than
guess when there is no single match. --pool trades a specific one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 17:49:12 +02:00

851 lines
32 KiB
Python

"""This standalone script demonstrates how to sell tokens on the PUMP AMM (pAMM) protocol.
It covers the complete flow from finding markets to executing sells with mayhem mode support.
Usage:
uv run cookbook/pumpswap/pumpswap_sell_token.py <MINT> [--slippage 0.25]
Key concepts demonstrated:
- Finding AMM pool addresses by token mint
- Parsing binary account data structures
- Dynamic fee recipient calculation (mayhem mode vs standard)
- Program Derived Address (PDA) derivation
- Transaction construction with compute budgets
- Slippage protection mechanisms
"""
import argparse
import asyncio
import os
import random
import struct
import sys
import base58
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.core import MemcmpOpts, TxOptsModel
from solders.compute_budget import set_compute_unit_limit, set_compute_unit_price
from solders.instruction import AccountMeta, Instruction
from solders.keypair import Keypair
from solders.message import MessageV0
from solders.pubkey import Pubkey
from solders.transaction import VersionedTransaction
from spl.token.instructions import get_associated_token_address
sys.path.append(
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "solana")
)
import solana_transaction_status as tx_status
load_dotenv()
# ============================================================================
# Configuration
# ============================================================================
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
PRIVATE_KEY = base58.b58decode(os.environ.get("SOLANA_PRIVATE_KEY"))
PAYER = Keypair.from_bytes(PRIVATE_KEY)
# Default for the command line below, not a fixed setting.
DEFAULT_SLIPPAGE = 0.25 # 25% - maximum acceptable price movement during trade
# Token configuration
# Program instruction discriminators (first 8 bytes identify the instruction)
SELL_DISCRIMINATOR = bytes.fromhex("33e685a4017f83ad")
# ============================================================================
# Solana Program IDs and System Accounts
# ============================================================================
SOL = Pubkey.from_string("So11111111111111111111111111111111111111112")
PUMP_AMM_PROGRAM_ID = Pubkey.from_string("pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA")
PUMP_SWAP_GLOBAL_CONFIG = Pubkey.from_string(
"ADyA8hdefvWN2dbGGWFotbzWxrAvLW83WG6QCVXvJKqw"
)
SYSTEM_TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
TOKEN_2022_PROGRAM = Pubkey.from_string("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")
SYSTEM_PROGRAM = Pubkey.from_string("11111111111111111111111111111111")
SYSTEM_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM = Pubkey.from_string(
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
)
PUMP_SWAP_EVENT_AUTHORITY = Pubkey.from_string(
"GS4CU59F31iL7aR2Q8zVS8DRrcRnXX1yjQ66TqNVQnaR"
)
PUMP_FEE_PROGRAM = Pubkey.from_string("pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ")
# 8 breaking-upgrade fee recipients.
# Two new accounts must be appended after pool-v2: the fee recipient (readonly)
# and its quote-mint ATA (mutable).
# Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md
BREAKING_FEE_RECIPIENTS = [
Pubkey.from_string("5YxQFdt3Tr9zJLvkFccqXVUwhdTWJQc1fFg2YPbxvxeD"),
Pubkey.from_string("9M4giFFMxmFGXtc3feFzRai56WbBqehoSeRE5GK7gf7"),
Pubkey.from_string("GXPFM2caqTtQYC2cJ5yJRi9VDkpsYZXzYdwYpGnLmtDL"),
Pubkey.from_string("3BpXnfJaUTiwXnJNe7Ej1rcbzqTTQUvLShZaWazebsVR"),
Pubkey.from_string("5cjcW9wExnJJiqgLjq7DEG75Pm6JBgE1hNv4B2vHXUW6"),
Pubkey.from_string("EHAAiTxcdDwQ3U4bU6YcMsQGaekdzLS3B5SmYo46kJtL"),
Pubkey.from_string("5eHhjP8JaYkz83CWwvGU2uMUXefd3AazWGx4gpcuEEYD"),
Pubkey.from_string("A7hAgCzFw14fejgCp387JUJRMNyz4j89JKnhtKU8piqW"),
]
# ============================================================================
# Constants for Account Structure Parsing
# ============================================================================
# Pool account structure offsets
POOL_DISCRIMINATOR_SIZE = 8
POOL_BASE_MINT_OFFSET = 43 # Where base_mint field starts in pool account data
POOL_MAYHEM_MODE_OFFSET = 243 # Where is_mayhem_mode flag is stored
POOL_IS_CASHBACK_OFFSET = 244
# virtual_quote_reserves is an i128 appended after the flags. Pool fields
# end at 261; live accounts are 301 bytes with trailing padding.
POOL_VIRTUAL_QUOTE_RESERVES_OFFSET = 245
POOL_VIRTUAL_QUOTE_RESERVES_SIZE = 16
POOL_MAYHEM_MODE_MIN_SIZE = 244 # Minimum size for pool data with mayhem flag
# GlobalConfig structure offsets
GLOBALCONFIG_DISCRIMINATOR_SIZE = 8
GLOBALCONFIG_ADMIN_SIZE = 32
GLOBALCONFIG_DEFAULT_FEE_RECIPIENT_SIZE = 32
GLOBALCONFIG_RESERVED_FEE_OFFSET = (
GLOBALCONFIG_DISCRIMINATOR_SIZE
+ GLOBALCONFIG_ADMIN_SIZE
+ GLOBALCONFIG_DEFAULT_FEE_RECIPIENT_SIZE
)
# Fee recipients
STANDARD_PUMPSWAP_FEE_RECIPIENT = Pubkey.from_string(
"7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ"
)
# Solana constants
LAMPORTS_PER_SOL = 1_000_000_000
COMPUTE_UNIT_PRICE = 10_000 # Micro-lamports per compute unit
COMPUTE_UNIT_BUDGET = 150_000 # Max compute units for transaction
# ============================================================================
# Market Discovery
# ============================================================================
# coin_creator sits after discriminator(8) + pool_bump(1) + index(2) + six
# pubkeys(192) + lp_supply(8). It is set only on a pool the coin graduated into
# and left at the default on every other pool, so it is what tells the canonical
# pool from a copy anyone can open against the same mint.
POOL_COIN_CREATOR_OFFSET = 211
def read_pool_coin_creator(data: bytes) -> Pubkey:
"""Read a pool account's coin_creator.
Args:
data: Raw pool account data, discriminator included
Returns:
The pubkey, or the default one if the account is too short to hold it
"""
end = POOL_COIN_CREATOR_OFFSET + 32
if len(data) < end:
return DEFAULT_COIN_CREATOR
return Pubkey.from_bytes(data[POOL_COIN_CREATOR_OFFSET:end])
async def get_market_address_by_base_mint(
client: AsyncClient, base_mint_address: Pubkey, amm_program_id: Pubkey
) -> Pubkey:
"""Find the AMM pool address for a specific token.
Uses getProgramAccounts RPC method with a memcmp filter to find the pool
that matches the given token mint address.
Args:
client: Solana RPC client
base_mint_address: Token mint to find the pool for
amm_program_id: PUMP AMM program address
Returns:
Address of the coin's canonical pool
Raises:
ValueError: If the coin has no canonical pool, or carries more than one
"""
# MemcmpOpts takes the bytes base58-encoded, which a Pubkey's str already is.
filters = [MemcmpOpts(offset=POOL_BASE_MINT_OFFSET, bytes=str(base_mint_address))]
response = await client.get_program_accounts(
amm_program_id, encoding="base64", filters=filters
)
canonical = [
account.pubkey
for account in response.value
if read_pool_coin_creator(bytes(account.account.data)) != DEFAULT_COIN_CREATOR
]
if not canonical:
raise ValueError(
f"No canonical PumpSwap pool for {base_mint_address}. "
f"{len(response.value)} pool(s) carry this base mint, none of them "
f"created by graduation. Pass --pool to trade a specific one."
)
if len(canonical) > 1:
listed = ", ".join(str(pool) for pool in canonical)
raise ValueError(
f"{len(canonical)} canonical pools for {base_mint_address}: {listed}. "
f"Pass --pool to choose one."
)
return canonical[0]
async def get_market_data(client: AsyncClient, market_address: Pubkey) -> dict:
"""Parse binary pool account data into a structured dictionary.
The pool account stores data in a specific binary format. This function
deserializes that data based on the known structure.
Args:
client: Solana RPC client
market_address: Address of the pool account
Returns:
Dictionary with parsed pool data fields
"""
response = await client.get_account_info(market_address, encoding="base64")
data = response.value.data
parsed_data: dict = {}
offset = POOL_DISCRIMINATOR_SIZE
# Field definitions: (name, type)
# Types: u8=1 byte, u16=2 bytes, u64/i64=8 bytes, pubkey=32 bytes
fields = [
("pool_bump", "u8"),
("index", "u16"),
("creator", "pubkey"),
("base_mint", "pubkey"),
("quote_mint", "pubkey"),
("lp_mint", "pubkey"),
("pool_base_token_account", "pubkey"),
("pool_quote_token_account", "pubkey"),
("lp_supply", "u64"),
("coin_creator", "pubkey"),
# Appended after coin_creator: is_mayhem_mode (243), is_cashback_coin
# (244), then virtual_quote_reserves as an i128 at 245..261. Live pool
# accounts are 301 bytes (fields end at 261, rest is padding).
("is_mayhem_mode", "u8"),
("is_cashback_coin", "u8"),
]
for field_name, field_type in fields:
if field_type == "pubkey":
value = data[offset : offset + 32]
parsed_data[field_name] = base58.b58encode(value).decode("utf-8")
offset += 32
elif field_type in {"u64", "i64"}:
format_char = "<Q" if field_type == "u64" else "<q"
parsed_data[field_name] = struct.unpack(
format_char, data[offset : offset + 8]
)[0]
offset += 8
elif field_type == "u16":
parsed_data[field_name] = struct.unpack("<H", data[offset : offset + 2])[0]
offset += 2
elif field_type == "u8":
parsed_data[field_name] = data[offset]
offset += 1
return parsed_data
# Program Derived Address (PDA) derivation: deterministic addresses derived from
# seeds and a program id, letting programs own accounts without a private key.
def find_coin_creator_vault(coin_creator: Pubkey) -> Pubkey:
"""Derive the PDA for the coin creator's fee vault.
The creator vault collects fees on behalf of the token creator.
This is a deterministic address that can be recalculated by anyone.
Args:
coin_creator: Public key of the token creator
Returns:
PDA of the creator's vault authority
"""
derived_address, _ = Pubkey.find_program_address(
[b"creator_vault", bytes(coin_creator)],
PUMP_AMM_PROGRAM_ID,
)
return derived_address
def find_fee_config() -> Pubkey:
"""Derive the PDA for the fee configuration account.
This account stores fee-related configuration for the AMM.
"""
derived_address, _ = Pubkey.find_program_address(
[b"fee_config", bytes(PUMP_AMM_PROGRAM_ID)],
PUMP_FEE_PROGRAM,
)
return derived_address
DEFAULT_COIN_CREATOR = Pubkey.default()
def find_pool_v2(base_mint: Pubkey) -> Pubkey:
"""Derive the PDA for the pool-v2 account (per-base-mint), required as the
last "pre-upgrade" account on every pump-swap buy/sell."""
derived_address, _ = Pubkey.find_program_address(
[b"pool-v2", bytes(base_mint)],
PUMP_AMM_PROGRAM_ID,
)
return derived_address
def find_user_volume_accumulator(user: Pubkey) -> Pubkey:
"""Derive the per-user volume accumulator PDA (pump-amm). Required for
cashback-pool sells (along with its WSOL ATA)."""
derived_address, _ = Pubkey.find_program_address(
[b"user_volume_accumulator", bytes(user)],
PUMP_AMM_PROGRAM_ID,
)
return derived_address
# Mayhem mode fee handling: the fee recipient changes with the pool's
# mayhem_mode flag.
async def get_reserved_fee_recipient_pumpswap(client: AsyncClient) -> Pubkey:
"""Fetch the mayhem mode fee recipient from GlobalConfig.
When mayhem mode is active, fees are redirected to a special recipient
stored in the GlobalConfig account.
Args:
client: Solana RPC client
Returns:
Public key of the mayhem mode fee recipient
"""
response = await client.get_account_info(PUMP_SWAP_GLOBAL_CONFIG, encoding="base64")
if not response.value or not response.value.data:
msg = "Cannot fetch GlobalConfig account"
raise ValueError(msg)
data = response.value.data
recipient_bytes = data[
GLOBALCONFIG_RESERVED_FEE_OFFSET : GLOBALCONFIG_RESERVED_FEE_OFFSET + 32
]
return Pubkey.from_bytes(recipient_bytes)
async def get_pumpswap_fee_recipients(
client: AsyncClient, pool: Pubkey
) -> tuple[Pubkey, Pubkey, bool]:
"""Determine fee recipient + whether the pool is cashback.
Returns:
Tuple of (fee_recipient_pubkey, fee_recipient_token_account, is_cashback)
"""
response = await client.get_account_info(pool, encoding="base64")
if not response.value or not response.value.data:
msg = "Cannot fetch pool account"
raise ValueError(msg)
pool_data = response.value.data
is_mayhem_mode = len(pool_data) >= POOL_MAYHEM_MODE_MIN_SIZE and bool(
pool_data[POOL_MAYHEM_MODE_OFFSET]
)
is_cashback = len(pool_data) > POOL_IS_CASHBACK_OFFSET and bool(
pool_data[POOL_IS_CASHBACK_OFFSET]
)
if is_mayhem_mode:
fee_recipient = await get_reserved_fee_recipient_pumpswap(client)
else:
fee_recipient = STANDARD_PUMPSWAP_FEE_RECIPIENT
# Get the fee recipient's WSOL token account
fee_recipient_token_account = get_associated_token_address(
fee_recipient, SOL, SYSTEM_TOKEN_PROGRAM
)
return (fee_recipient, fee_recipient_token_account, is_cashback)
# ============================================================================
# Price Calculation
# ============================================================================
async def read_virtual_quote_reserves(client: AsyncClient, pool: Pubkey) -> int:
"""Read Pool::virtual_quote_reserves, the field appended after the flags.
Args:
client: Solana RPC client
pool: Pool (market) address
Returns:
Raw virtual quote reserves, or 0 if the account predates the field
"""
response = await client.get_account_info(pool, encoding="base64")
if not response.value or not response.value.data:
return 0
data = response.value.data
end = POOL_VIRTUAL_QUOTE_RESERVES_OFFSET + POOL_VIRTUAL_QUOTE_RESERVES_SIZE
if len(data) < end:
return 0
return int.from_bytes(
data[POOL_VIRTUAL_QUOTE_RESERVES_OFFSET:end], "little", signed=True
)
async def calculate_token_pool_price(
client: AsyncClient,
pool_base_token_account: Pubkey,
pool_quote_token_account: Pubkey,
virtual_quote_reserves: int = 0,
) -> float:
"""Calculate current token price from AMM pool reserves.
Price is the ratio of *effective* quote reserves to base reserves:
effective_quote_reserves =
pool_quote_token_account.amount + Pool::virtual_quote_reserves
PumpSwap added `virtual_quote_reserves` to the Pool account. Upstream's
release note says it is 0 on every pool, but that is out of date: live pools
carry non-zero values, and quoting off the raw vault balance under-prices
them. Always add it.
Args:
client: Solana RPC client
pool_base_token_account: Pool's token account (the token being priced)
virtual_quote_reserves: Pool::virtual_quote_reserves, in raw quote units
Returns:
Price in quote asset per token
"""
base_balance_resp = await client.get_token_account_balance(
pool_base_token_account, commitment=Confirmed
)
quote_balance_resp = await client.get_token_account_balance(
pool_quote_token_account
)
base_amount = float(base_balance_resp.value.ui_amount)
quote_decimals = int(quote_balance_resp.value.decimals)
quote_raw = int(quote_balance_resp.value.amount) + int(virtual_quote_reserves)
quote_amount = quote_raw / 10**quote_decimals
return quote_amount / base_amount
# ============================================================================
# Token Program Determination
# ============================================================================
# The `decimals` byte sits at this offset in both SPL Token and Token-2022 mints;
# Token-2022 appends its extensions after the base struct and never moves it.
MINT_DECIMALS_OFFSET = 44
async def get_mint_info(
client: AsyncClient, mint_address: Pubkey
) -> tuple[Pubkey, int]:
"""Read a mint's token program and decimals from one account fetch.
Both come off the same `getAccountInfo`, so the decimals cost nothing extra.
Every coin that graduated from a pump.fun bonding curve has 6, but a pool
that was never a bonding-curve coin routinely has 9, and a factor of 1000
scales the quote and the slippage floor together, so the trade reverts
`ExceededSlippage` (6004) rather than merely mispricing.
Args:
client: Connected RPC client
mint_address: The mint to inspect
Returns:
(token program that owns the mint, the mint's decimals)
Raises:
ValueError: If the mint is missing or owned by an unknown program
"""
mint_info = await client.get_account_info(mint_address)
if not mint_info.value:
raise ValueError(f"Could not fetch mint info for {mint_address}")
owner = mint_info.value.owner
if owner not in (SYSTEM_TOKEN_PROGRAM, TOKEN_2022_PROGRAM):
raise ValueError(
f"Mint account {mint_address} is owned by an unknown program: {owner}"
)
data = mint_info.value.data
if len(data) <= MINT_DECIMALS_OFFSET:
raise ValueError(f"Mint account {mint_address} is too short to hold decimals")
return owner, data[MINT_DECIMALS_OFFSET]
# ============================================================================
# Associated Token Account (ATA) Creation
# ============================================================================
def create_ata_idempotent_ix(payer_pubkey: Pubkey) -> Instruction:
"""Create instruction to initialize a WSOL ATA if it doesn't exist.
Idempotent means this instruction won't fail if the ATA already exists.
See: https://github.com/solana-program/associated-token-account/blob/main/program/src/instruction.rs
Args:
payer_pubkey: Account that will pay for ATA creation
Returns:
Instruction to create the ATA
"""
associated_token_address = get_associated_token_address(payer_pubkey, SOL)
instruction_accounts = [
AccountMeta(pubkey=payer_pubkey, is_signer=True, is_writable=True),
AccountMeta(pubkey=associated_token_address, is_signer=False, is_writable=True),
AccountMeta(pubkey=payer_pubkey, is_signer=True, is_writable=True),
AccountMeta(pubkey=SOL, is_signer=False, is_writable=False),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=SYSTEM_TOKEN_PROGRAM, is_signer=False, is_writable=False),
]
# Instruction data: single byte with value 1 = CreateIdempotent
return Instruction(
SYSTEM_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM, bytes([1]), instruction_accounts
)
# ============================================================================
# Token Selling
# ============================================================================
async def sell_pump_swap(
client: AsyncClient,
market: Pubkey,
payer: Keypair,
base_mint: Pubkey,
token_program_id: Pubkey,
user_base_token_account: Pubkey,
user_quote_token_account: Pubkey,
pool_base_token_account: Pubkey,
pool_quote_token_account: Pubkey,
coin_creator_vault_authority: Pubkey,
coin_creator_vault_ata: Pubkey,
coin_creator: Pubkey,
base_decimals: int,
slippage: float = 0.25,
) -> str | None:
"""Execute a token sell on the PUMP AMM with slippage protection.
Reads the balance and pool price, floors the SOL output by the slippage
tolerance, then sends.
Args:
client: Solana RPC client
market: AMM pool address
payer: Wallet keypair for signing
base_mint: Token mint address
user_quote_token_account: User's WSOL account
pool_quote_token_account: Pool's WSOL account
coin_creator_vault_authority: Creator vault PDA
coin_creator_vault_ata: Creator's WSOL account
slippage: Maximum acceptable slippage (0.25 = 25%)
Returns:
Transaction signature if successful, None otherwise
"""
# Read at "confirmed", not solana-py's "finalized" default: an ATA created
# by a buy moments earlier does not exist at finalized yet ("could not find
# account"), and a post-sell balance still reads pre-sell, so the next
# transfer reverts with insufficient funds.
token_balance = int(
(
await client.get_token_account_balance(
user_base_token_account, commitment=Confirmed
)
).value.amount
)
token_balance_decimal = token_balance / 10**base_decimals
print(f"Token balance: {token_balance_decimal}")
if token_balance == 0:
print("No tokens to sell.")
return None
token_price_sol = await calculate_token_pool_price(
client,
pool_base_token_account,
pool_quote_token_account,
await read_virtual_quote_reserves(client, market),
)
print(f"Price per Token: {token_price_sol:.20f} SOL")
# Calculate minimum SOL we're willing to receive (slippage protection)
expected_sol_output = token_balance_decimal * token_price_sol
min_sol_output = int((expected_sol_output * (1 - slippage)) * LAMPORTS_PER_SOL)
print(f"Selling {token_balance_decimal} tokens")
print(f"Minimum SOL output: {min_sol_output / LAMPORTS_PER_SOL:.10f} SOL")
# Get fee recipient based on mayhem mode + detect cashback pool
(
fee_recipient,
fee_recipient_token_account,
is_cashback,
) = await get_pumpswap_fee_recipients(client, market)
# Cashback-pool sells append two writable accounts (user_volume_accumulator
# quote ATA, then the accumulator PDA itself) BEFORE pool-v2.
user_volume_accumulator = find_user_volume_accumulator(payer.pubkey())
user_volume_accumulator_quote_ata = get_associated_token_address(
user_volume_accumulator, SOL, SYSTEM_TOKEN_PROGRAM
)
# Build account list for sell instruction
# Order matters! Must match the program's expected account layout
accounts = [
AccountMeta(pubkey=market, is_signer=False, is_writable=True),
AccountMeta(pubkey=payer.pubkey(), is_signer=True, is_writable=True),
AccountMeta(pubkey=PUMP_SWAP_GLOBAL_CONFIG, is_signer=False, is_writable=False),
AccountMeta(pubkey=base_mint, is_signer=False, is_writable=False),
AccountMeta(pubkey=SOL, is_signer=False, is_writable=False),
AccountMeta(pubkey=user_base_token_account, is_signer=False, is_writable=True),
AccountMeta(pubkey=user_quote_token_account, is_signer=False, is_writable=True),
AccountMeta(pubkey=pool_base_token_account, is_signer=False, is_writable=True),
AccountMeta(pubkey=pool_quote_token_account, is_signer=False, is_writable=True),
AccountMeta(pubkey=fee_recipient, is_signer=False, is_writable=False),
AccountMeta(
pubkey=fee_recipient_token_account, is_signer=False, is_writable=True
),
AccountMeta(
pubkey=token_program_id, is_signer=False, is_writable=False
), # Use dynamic token_program_id
AccountMeta(pubkey=SYSTEM_TOKEN_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(pubkey=SYSTEM_PROGRAM, is_signer=False, is_writable=False),
AccountMeta(
pubkey=SYSTEM_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM,
is_signer=False,
is_writable=False,
),
AccountMeta(
pubkey=PUMP_SWAP_EVENT_AUTHORITY, is_signer=False, is_writable=False
),
AccountMeta(pubkey=PUMP_AMM_PROGRAM_ID, is_signer=False, is_writable=False),
AccountMeta(pubkey=coin_creator_vault_ata, is_signer=False, is_writable=True),
AccountMeta(
pubkey=coin_creator_vault_authority, is_signer=False, is_writable=False
),
AccountMeta(pubkey=find_fee_config(), is_signer=False, is_writable=False),
AccountMeta(pubkey=PUMP_FEE_PROGRAM, is_signer=False, is_writable=False),
]
# Cashback pools require user_volume_accumulator_quote_ata + the accumulator
# PDA itself (both writable) BEFORE pool-v2. Confirmed against on-chain
# post-cutover cashback sell
# (sig 4ei1cJV7uaENJeb5p8prVKiTApTouTh94r9HqPNbj7oJH52X8mEiXhrVNKUgtB9WeZB8jZANnmuSkdTuJ59y8NP3).
if is_cashback:
accounts.extend(
[
AccountMeta(
pubkey=user_volume_accumulator_quote_ata,
is_signer=False,
is_writable=True,
),
AccountMeta(
pubkey=user_volume_accumulator, is_signer=False, is_writable=True
),
]
)
# pool-v2 belongs only to a *canonical* pool — one that graduated from a
# pump.fun bonding curve. `Pool.coin_creator` is the discriminator: it is set
# for canonical pools and left at `Pubkey::default()` for every other pool
# (upstream PUMP_SWAP_CREATOR_FEE_README.md). The two buyback accounts below
# are required either way and are read *positionally* from the end, so on a
# non-canonical pool sending pool-v2 shifts the pair by one and the program
# rejects the pool-v2 PDA with `BuybackFeeRecipientNotAuthorized` (6053).
# BREAKING_FEE_RECIPIENT.md qualifies the "after pool-v2" ordering with "for
# coins that graduate from bonding curve".
if coin_creator != DEFAULT_COIN_CREATOR:
accounts.append(
AccountMeta(
pubkey=find_pool_v2(base_mint), is_signer=False, is_writable=False
)
)
# 2 accounts appended AFTER pool-v2: breaking-fee recipient (readonly) + its quote-mint ATA (mutable).
# Sell counts on a canonical pool: 24 non-cashback / 26 cashback. One fewer
# on a non-canonical pool, which carries no pool-v2: 23 / 25.
# Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md
breaking_fee_recipient = random.choice(BREAKING_FEE_RECIPIENTS)
breaking_fee_quote_ata = get_associated_token_address(
breaking_fee_recipient, SOL, SYSTEM_TOKEN_PROGRAM
)
accounts.extend(
[
AccountMeta(
pubkey=breaking_fee_recipient, is_signer=False, is_writable=False
),
AccountMeta(
pubkey=breaking_fee_quote_ata, is_signer=False, is_writable=True
),
]
)
# Instruction data format: discriminator (8 bytes) + amount (8 bytes) + min_out (8 bytes)
# All integers are little-endian (<)
data = (
SELL_DISCRIMINATOR
+ struct.pack("<Q", token_balance) # Amount to sell
+ struct.pack("<Q", min_sol_output) # Minimum SOL to receive
)
# Set compute budget to avoid transaction failures
compute_limit_ix = set_compute_unit_limit(COMPUTE_UNIT_BUDGET)
compute_price_ix = set_compute_unit_price(COMPUTE_UNIT_PRICE)
# Ensure WSOL ATA exists (needed to receive SOL from sell)
create_ata_ix = create_ata_idempotent_ix(payer.pubkey())
sell_ix = Instruction(PUMP_AMM_PROGRAM_ID, data, accounts)
# Build and sign transaction
blockhash_resp = await client.get_latest_blockhash()
msg = MessageV0.try_compile(
payer.pubkey(),
[compute_limit_ix, compute_price_ix, create_ata_ix, sell_ix],
[],
blockhash_resp.value.blockhash,
)
tx = VersionedTransaction(message=msg, keypairs=[payer])
try:
# Skip preflight to send transaction faster (useful in competitive scenarios)
tx_sig = await client.send_transaction(
tx, opts=TxOptsModel(skip_preflight=True, preflight_commitment=Confirmed)
)
tx_hash = tx_sig.value
print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}")
await tx_status.confirm_and_assert(client, tx_hash)
print("Transaction confirmed")
return tx_hash
except Exception as e:
print(f"Error: {e!s}")
return None
# ============================================================================
# Main Execution
# ============================================================================
async def sell(token_mint: Pubkey, slippage: float, pool: Pubkey | None = None) -> None:
"""Execute the complete sell flow.
Args:
token_mint: The coin to sell
slippage: Maximum acceptable price movement
pool: Trade this pool rather than the coin's canonical one
"""
async with AsyncClient(RPC_ENDPOINT, timeout=120) as client:
# Step 1: Find the pool address for our token
market_address = pool or await get_market_address_by_base_mint(
client, token_mint, PUMP_AMM_PROGRAM_ID
)
# Step 2: Parse pool data to get necessary accounts
market_data = await get_market_data(client, market_address)
# This script wraps SOL and caps with LAMPORTS_PER_SOL, but prices off
# the pool's own decimals. On a pool quoting anything else the two
# disagree and compound, so refuse rather than size the trade wrong.
pool_quote_mint = Pubkey.from_string(market_data["quote_mint"])
if pool_quote_mint != SOL:
raise ValueError(
f"Pool {market_address} quotes in {pool_quote_mint}, not SOL. "
f"This script wraps SOL and prices in it, so it cannot trade "
f"that pool."
)
# Determine token program ID for the base mint
token_program_id, base_decimals = await get_mint_info(client, token_mint)
# Step 3: Derive PDAs needed for the transaction
coin_creator_vault_authority = find_coin_creator_vault(
Pubkey.from_string(market_data["coin_creator"])
)
coin_creator_vault_ata = get_associated_token_address(
coin_creator_vault_authority, SOL, SYSTEM_TOKEN_PROGRAM
)
# Step 4: Execute the sell
await sell_pump_swap(
client,
market_address,
PAYER,
token_mint,
token_program_id,
get_associated_token_address(PAYER.pubkey(), token_mint, token_program_id),
get_associated_token_address(PAYER.pubkey(), SOL, SYSTEM_TOKEN_PROGRAM),
Pubkey.from_string(market_data["pool_base_token_account"]),
Pubkey.from_string(market_data["pool_quote_token_account"]),
coin_creator_vault_authority,
coin_creator_vault_ata,
Pubkey.from_string(market_data["coin_creator"]),
base_decimals,
slippage,
)
def main() -> None:
"""Parse the command line and run the sell."""
parser = argparse.ArgumentParser(description="Sell a coin on the PumpSwap AMM")
parser.add_argument("mint", help="The coin's mint address")
parser.add_argument(
"--slippage",
type=float,
default=DEFAULT_SLIPPAGE,
help=f"Maximum acceptable price movement (default {DEFAULT_SLIPPAGE})",
)
parser.add_argument(
"--pool",
help=(
"Trade this pool instead of the coin's canonical one. Needed for a "
"pool nobody graduated into, which the default selection refuses."
),
)
args = parser.parse_args()
try:
asyncio.run(
sell(
Pubkey.from_string(args.mint),
args.slippage,
Pubkey.from_string(args.pool) if args.pool else None,
)
)
except ValueError as e:
print(e)
sys.exit(1)
if __name__ == "__main__":
main()