mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-11-02 06:16:10 +00:00
* 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>
851 lines
32 KiB
Python
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()
|