mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-08-15 00:08:04 +00:00
Refresh the vendored IDLs from pump-fun/pump-public-docs @ 9c82f61 and move all pump.fun trading onto the v2 instruction interface. This is required, not optional: legacy buy/sell cannot trade coins paired against a quote asset other than SOL, and USDC is already whitelisted in the on-chain Global account. Protocol changes absorbed: - buy_v2 (27 accounts) / sell_v2 (26 accounts) replace the legacy instructions. Every account is mandatory and the order is identical for all coins, so the conditional cashback/mayhem account lists are gone. Legacy remains available via PumpFunInstructionBuilder(use_legacy_instructions=True). - BondingCurve is 151 bytes: virtual_sol_reserves -> virtual_quote_reserves, real_sol_reserves -> real_quote_reserves, plus quote_mint at offset 83. Old field names are kept as aliases so existing callers keep working. - v2 instruction data drops the track_volume OptionBool; amounts are in the quote mint's raw units rather than always lamports. - create_v2 carries a non-SOL quote mint as optional remaining accounts 17-19, and CreateEvent gained quote_mint, so extreme_fast_mode can resolve the quote asset without an extra fetch. USDC support: new trade.quote_amounts and filters.allowed_quote_mints config, accepting "sol"/"usdc" aliases or raw mints. Amounts are per-quote-mint because 1 USDC and 1 SOL are not interchangeable. A coin whose quote mint has no configured amount is skipped rather than traded at the wrong size, so SOL-only configs are unaffected. Bug fixes found while verifying: - The logs and blocks listeners set no websocket max_size, so any frame over 1 MiB closed the connection with 1009 and the token in it was lost. Raised to 32 MiB. - PumpSwap priced against the raw quote vault balance, ignoring the new Pool.virtual_quote_reserves (i128 at offset 245; live pools are 301 bytes). Upstream's note that this field is 0 everywhere is out of date: a live pool carries 17.58 SOL against a 148 SOL vault, a 10.15% price error. - The seller read curve state once at confirmed commitment and silently fell back to create-time values, risking a stale creator_vault and ConstraintSeeds. It now retries at processed, matching the buyer. - Account cleanup would burn wrapped SOL when force_burn was set, destroying value that closing the account returns. WSOL is now closed without burning. - The mint scripts treated a landed transaction as a successful one, so a reverted buy printed as success. They now assert the on-chain result. Compute unit limits retuned from mainnet measurements: buy 100k -> 180k, sell 60k -> 120k. Mint-and-buy is no longer atomic, because create_v2 plus buy_v2 exceeds the 1232-byte transaction limit; both mint scripts send two transactions. Adds learning-examples/pump_v2.py as one shared, standalone v2 toolkit for the example scripts, and three verification scripts: an offline layout check against the IDL, a no-funds mainnet simulation, and a live listener matrix that buys, sells and closes the ATA per listener. Verified on mainnet: all four listeners (geyser, logs, blocks, pumpportal) and all eight example scripts completed a real buy, sell and ATA close, each confirmed by reading the transaction result back rather than trusting confirmation alone. The USDC path is verified structurally only; no USDC-paired coin could be found on-chain to exercise it. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
699 lines
26 KiB
Python
699 lines
26 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.
|
|
|
|
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 asyncio
|
|
import os
|
|
import random
|
|
import struct
|
|
|
|
import base58
|
|
from dotenv import load_dotenv
|
|
from solana.rpc.async_api import AsyncClient
|
|
from solana.rpc.commitment import Confirmed
|
|
from solana.rpc.types import MemcmpOpts, TxOpts
|
|
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 Message
|
|
from solders.pubkey import Pubkey
|
|
from solders.transaction import VersionedTransaction
|
|
from spl.token.instructions import get_associated_token_address
|
|
|
|
load_dotenv()
|
|
|
|
# ============================================================================
|
|
# Configuration
|
|
# ============================================================================
|
|
|
|
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
|
|
import sys
|
|
|
|
TOKEN_MINT = Pubkey.from_string(sys.argv[1] if len(sys.argv) > 1 else "...") # Pass mint as argv[1]
|
|
PRIVATE_KEY = base58.b58decode(os.environ.get("SOLANA_PRIVATE_KEY"))
|
|
PAYER = Keypair.from_bytes(PRIVATE_KEY)
|
|
SLIPPAGE = 0.25 # 25% - maximum acceptable price movement during trade
|
|
|
|
# Token configuration
|
|
TOKEN_DECIMALS = 6 # Standard for most pump.fun tokens
|
|
|
|
# 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 (pump-swap program upgrade 2026-04-28).
|
|
# 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
|
|
# ============================================================================
|
|
|
|
|
|
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 AMM pool (market) for the token
|
|
"""
|
|
filters = [MemcmpOpts(offset=POOL_BASE_MINT_OFFSET, bytes=bytes(base_mint_address))]
|
|
response = await client.get_program_accounts(
|
|
amm_program_id, encoding="base64", filters=filters
|
|
)
|
|
return response.value[0].pubkey
|
|
|
|
|
|
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
|
|
# ============================================================================
|
|
# PDAs are deterministic addresses derived from seeds and a program ID.
|
|
# They allow programs to own accounts without needing 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
|
|
|
|
|
|
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
|
|
# ============================================================================
|
|
# Mayhem mode is a special fee structure where fees go to a different recipient.
|
|
# The fee recipient changes dynamically based on 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 (17.58 SOL observed on a 148 SOL pool, i.e. quoting
|
|
off the raw vault balance under-prices by ~10.6%). Always add it.
|
|
|
|
Args:
|
|
client: Solana RPC client
|
|
pool_base_token_account: Pool's token account (the token being priced)
|
|
pool_quote_token_account: Pool's quote account
|
|
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)
|
|
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
|
|
# ============================================================================
|
|
|
|
|
|
async def get_token_program_id(client: AsyncClient, mint_address: Pubkey) -> Pubkey:
|
|
"""Determines if a mint uses TokenProgram or Token2022Program."""
|
|
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 == SYSTEM_TOKEN_PROGRAM:
|
|
return SYSTEM_TOKEN_PROGRAM
|
|
elif owner == TOKEN_2022_PROGRAM:
|
|
return TOKEN_2022_PROGRAM
|
|
else:
|
|
raise ValueError(
|
|
f"Mint account {mint_address} is owned by an unknown program: {owner}"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# 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,
|
|
slippage: float = 0.25,
|
|
) -> str | None:
|
|
"""Execute a token sell on the PUMP AMM with slippage protection.
|
|
|
|
This function:
|
|
1. Fetches current token balance and pool price
|
|
2. Calculates minimum SOL output with slippage tolerance
|
|
3. Constructs and sends the sell transaction
|
|
|
|
Args:
|
|
client: Solana RPC client
|
|
market: AMM pool address
|
|
payer: Wallet keypair for signing
|
|
base_mint: Token mint address
|
|
user_base_token_account: User's token account
|
|
user_quote_token_account: User's WSOL account
|
|
pool_base_token_account: Pool's token 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
|
|
"""
|
|
token_balance = int(
|
|
(await client.get_token_account_balance(user_base_token_account)).value.amount
|
|
)
|
|
token_balance_decimal = token_balance / 10**TOKEN_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 PDA (per-base-mint) — the last "pre-upgrade" account.
|
|
accounts.append(
|
|
AccountMeta(pubkey=find_pool_v2(base_mint), is_signer=False, is_writable=False)
|
|
)
|
|
# 2 accounts required by the 2026-04-28 pump-swap upgrade, appended AFTER
|
|
# pool-v2: breaking-fee recipient (readonly) + its quote-mint ATA (mutable).
|
|
# Sell counts: 24 non-cashback / 26 cashback.
|
|
# 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 = Message.new_with_blockhash(
|
|
[compute_limit_ix, compute_price_ix, create_ata_ix, sell_ix],
|
|
payer.pubkey(),
|
|
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=TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
|
|
)
|
|
tx_hash = tx_sig.value
|
|
print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}")
|
|
|
|
await client.confirm_transaction(tx_hash, commitment="confirmed")
|
|
print("Transaction confirmed")
|
|
return tx_hash
|
|
except Exception as e:
|
|
print(f"Error: {e!s}")
|
|
return None
|
|
|
|
|
|
# ============================================================================
|
|
# Main Execution
|
|
# ============================================================================
|
|
|
|
|
|
async def main() -> None:
|
|
"""Execute the complete sell flow."""
|
|
async with AsyncClient(RPC_ENDPOINT, timeout=120) as client:
|
|
# Step 1: Find the pool address for our token
|
|
market_address = 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)
|
|
|
|
# Determine token program ID for the base mint
|
|
token_program_id = await get_token_program_id(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,
|
|
SLIPPAGE,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|