feat(pumpfun): migrate to buy_v2/sell_v2 and support non-SOL quote assets (#176)

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>
This commit is contained in:
Anton Sauchyk
2026-07-28 17:58:33 +02:00
committed by GitHub
parent 3b88a06d9d
commit 02343b775b
34 changed files with 10949 additions and 3987 deletions
+3
View File
@@ -98,6 +98,9 @@ async def start_bot(config_path: str):
# Extreme fast mode settings
extreme_fast_mode=cfg["trade"].get("extreme_fast_mode", False),
extreme_fast_token_amount=cfg["trade"].get("extreme_fast_token_amount", 30),
# Quote asset configuration (pump.fun non-SOL pairs)
quote_amounts=cfg["trade"].get("quote_amounts"),
allowed_quote_mints=cfg["filters"].get("allowed_quote_mints"),
# Exit strategy configuration
exit_strategy=cfg["trade"].get("exit_strategy", "time_based"),
take_profit_percentage=cfg["trade"].get("take_profit_percentage"),
+10 -1
View File
@@ -69,7 +69,16 @@ class AccountCleanupManager:
balance = await self.client.get_token_account_balance(ata)
instructions = []
if balance > 0 and self.close_with_force_burn:
# Never burn wrapped SOL: closing a WSOL account already returns
# both the wrapped lamports and the rent to the owner, so burning
# first would destroy real value.
if balance > 0 and mint == SystemAddresses.WSOL_MINT:
logger.info(
f"Unwrapping {balance} lamports of wrapped SOL from {ata} "
f"by closing it (burn skipped)"
)
elif balance > 0 and self.close_with_force_burn:
logger.info(
f"Burning {balance} tokens from ATA {ata} (mint: {mint})..."
)
+33
View File
@@ -188,6 +188,10 @@ def validate_config(config: dict) -> None:
if "Missing required config key" not in str(e):
raise
# Quote-asset configuration must resolve before the bot starts trading,
# otherwise a bad mint alias only surfaces on the first non-SOL coin.
validate_quote_config(config)
# Platform-specific validation
platform_str = config.get("platform", "pump_fun")
try:
@@ -201,6 +205,35 @@ def validate_config(config: dict) -> None:
raise
def validate_quote_config(config: dict) -> None:
"""Validate trade.quote_amounts and filters.allowed_quote_mints.
Args:
config: Loaded bot configuration
Raises:
ValueError: If a quote mint alias/address or amount is invalid
"""
from core.pubkeys import resolve_quote_amounts, resolve_quote_mint
quote_amounts = config.get("trade", {}).get("quote_amounts")
if quote_amounts is not None:
if not isinstance(quote_amounts, dict):
raise ValueError(
"trade.quote_amounts must be a mapping of quote mint to amount"
)
resolve_quote_amounts(quote_amounts)
allowed = config.get("filters", {}).get("allowed_quote_mints")
if allowed is not None:
if not isinstance(allowed, list) or not allowed:
raise ValueError(
"filters.allowed_quote_mints must be a non-empty list of quote mints"
)
for mint in allowed:
resolve_quote_mint(mint)
def validate_platform_config(config: dict, platform: Platform) -> None:
"""Validate platform-specific configuration requirements."""
# Check if platform is supported
+70 -39
View File
@@ -19,6 +19,7 @@ from solders.message import Message
from solders.pubkey import Pubkey
from solders.transaction import Transaction
from core.pubkeys import is_sol_paired
from core.rpc_rate_limiter import TokenBucketRateLimiter
from utils.logger import get_logger
@@ -359,21 +360,30 @@ class SolanaClient:
return None
async def get_buy_transaction_details(
self, signature: str, mint: Pubkey, sol_destination: Pubkey
self,
signature: str,
mint: Pubkey,
sol_destination: Pubkey,
quote_mint: Pubkey | None = None,
) -> tuple[int | None, int | None]:
"""Get actual tokens received and SOL spent from a buy transaction.
"""Get actual tokens received and quote spent from a buy transaction.
Uses preBalances/postBalances to find exact SOL transferred to the
pool/curve and pre/post token balance diff to find tokens received.
For coins paired against an SPL quote asset (e.g. USDC) the quote spend
does not show up in lamport balances, so it is read from the quote
mint's token balance deltas instead.
Args:
signature: Transaction signature
mint: Token mint address
sol_destination: Address where SOL is sent (bonding curve for pump.fun,
quote_vault for letsbonk)
quote_mint: Quote mint of the coin. Pass None or wrapped SOL for
SOL-paired coins.
Returns:
Tuple of (tokens_received_raw, sol_spent_lamports), or (None, None)
Tuple of (tokens_received_raw, quote_spent_raw), or (None, None)
"""
result = await self._get_transaction_result(signature)
if not result:
@@ -384,47 +394,29 @@ class SolanaClient:
# Check for transaction execution errors (e.g., MaxLoadedAccountsDataSizeExceeded)
tx_err = meta.get("err")
if tx_err:
logger.error(
f"Transaction {signature[:16]}... failed with error: {tx_err}"
)
logger.error(f"Transaction {signature[:16]}... failed with error: {tx_err}")
return None, None
mint_str = str(mint)
# Get tokens received from pre/post token balance diff
# This works for Token2022 where owner might be different
tokens_received = None
pre_token_balances = meta.get("preTokenBalances", [])
post_token_balances = meta.get("postTokenBalances", [])
tokens_received = self._extract_positive_token_diff(meta, str(mint))
if tokens_received is not None:
logger.info(f"Tokens received from tx: {tokens_received}")
# Build lookup by account index
pre_by_idx = {b.get("accountIndex"): b for b in pre_token_balances}
post_by_idx = {b.get("accountIndex"): b for b in post_token_balances}
# Find positive token diff for our mint (user receiving tokens)
all_indices = set(pre_by_idx.keys()) | set(post_by_idx.keys())
for idx in all_indices:
pre = pre_by_idx.get(idx)
post = post_by_idx.get(idx)
# Check if this is our mint
balance_mint = (post or pre).get("mint", "")
if balance_mint != mint_str:
continue
pre_amount = (
int(pre.get("uiTokenAmount", {}).get("amount", 0)) if pre else 0
)
post_amount = (
int(post.get("uiTokenAmount", {}).get("amount", 0)) if post else 0
)
diff = post_amount - pre_amount
# Positive diff means tokens received (not the bonding curve's negative)
if diff > 0:
tokens_received = diff
logger.info(f"Tokens received from tx: {tokens_received}")
break
# Non-SOL quote assets move as SPL token transfers, so the lamport
# deltas below would report only rent/fees. Read the quote spend from
# the quote mint's token balance deltas: the positive diff is the
# curve's quote vault receiving what the buyer paid.
if quote_mint is not None and not is_sol_paired(quote_mint):
quote_spent = self._extract_positive_token_diff(meta, str(quote_mint))
if quote_spent is None:
logger.warning(
f"No positive {quote_mint} balance diff found in tx "
f"{signature[:16]}...; cannot determine quote spent"
)
else:
logger.info(f"Quote spent from tx: {quote_spent} (mint {quote_mint})")
return tokens_received, quote_spent
# Get SOL spent from preBalances/postBalances at sol_destination
sol_destination_str = str(sol_destination)
@@ -451,6 +443,45 @@ class SolanaClient:
return tokens_received, sol_spent
@staticmethod
def _extract_positive_token_diff(meta: dict, mint_str: str) -> int | None:
"""Find the largest positive token balance change for a mint in a tx.
Args:
meta: Transaction meta containing pre/postTokenBalances
mint_str: Mint address to look for
Returns:
Raw positive balance delta, or None if no account gained this mint
"""
pre_by_idx = {
b.get("accountIndex"): b for b in meta.get("preTokenBalances", [])
}
post_by_idx = {
b.get("accountIndex"): b for b in meta.get("postTokenBalances", [])
}
best: int | None = None
for idx in set(pre_by_idx) | set(post_by_idx):
pre = pre_by_idx.get(idx)
post = post_by_idx.get(idx)
if (post or pre).get("mint", "") != mint_str:
continue
pre_amount = (
int(pre.get("uiTokenAmount", {}).get("amount", 0)) if pre else 0
)
post_amount = (
int(post.get("uiTokenAmount", {}).get("amount", 0)) if post else 0
)
diff = post_amount - pre_amount
if diff > 0 and (best is None or diff > best):
best = diff
return best
async def _get_transaction_result(self, signature: str) -> dict | None:
"""Fetch transaction result from RPC.
+158
View File
@@ -38,6 +38,161 @@ SOL_MINT: Final[Pubkey] = Pubkey.from_string(
"So11111111111111111111111111111111111111112"
)
# Quote mints supported by pump.fun's v2 trade instructions.
# `bonding_curve.quote_mint` is Pubkey::default() (all zeros) for SOL-paired
# coins; the v2 instructions still expect wrapped SOL to be passed explicitly.
# Doc: pump-public-docs README, "New Bonding Curve Trade Instructions".
DEFAULT_PUBKEY: Final[Pubkey] = Pubkey.from_string("11111111111111111111111111111111")
WSOL_MINT: Final[Pubkey] = SOL_MINT
USDC_MINT: Final[Pubkey] = Pubkey.from_string(
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
)
# Decimals per quote mint. SOL uses 9, USDC uses 6.
QUOTE_DECIMALS: Final[dict[Pubkey, int]] = {
WSOL_MINT: 9,
USDC_MINT: 6,
}
# Token program that owns each quote mint. Both current quote mints are
# legacy SPL Token, but the v2 instructions take quote_token_program
# separately from base_token_program, so keep them decoupled.
QUOTE_TOKEN_PROGRAMS: Final[dict[Pubkey, Pubkey]] = {
WSOL_MINT: TOKEN_PROGRAM,
USDC_MINT: TOKEN_PROGRAM,
}
def quote_decimals(quote_mint: Pubkey) -> int:
"""Get the decimal count for a quote mint, defaulting to SOL's 9.
Args:
quote_mint: Quote mint address
Returns:
Number of decimals used by the quote mint
"""
return QUOTE_DECIMALS.get(quote_mint, 9)
def quote_units_per_token(quote_mint: Pubkey) -> int:
"""Get the raw-units-per-whole-unit factor for a quote mint.
Args:
quote_mint: Quote mint address
Returns:
10 ** decimals for the quote mint (1e9 for SOL, 1e6 for USDC)
"""
return 10 ** quote_decimals(quote_mint)
def quote_token_program(quote_mint: Pubkey) -> Pubkey:
"""Get the token program owning a quote mint, defaulting to SPL Token.
Args:
quote_mint: Quote mint address
Returns:
Token program id for the quote mint
"""
return QUOTE_TOKEN_PROGRAMS.get(quote_mint, TOKEN_PROGRAM)
def normalize_quote_mint(quote_mint: Pubkey | None) -> Pubkey:
"""Resolve a bonding curve's quote_mint into a concrete mint address.
SOL-paired coins carry Pubkey::default() on-chain but must be traded with
wrapped SOL passed as the quote mint.
Args:
quote_mint: Raw quote_mint from the bonding curve, or None
Returns:
Wrapped SOL for SOL-paired coins, otherwise the quote mint unchanged
"""
if quote_mint is None or quote_mint == DEFAULT_PUBKEY:
return WSOL_MINT
return quote_mint
# Friendly aliases accepted in bot YAML so configs don't need raw mints.
QUOTE_MINT_ALIASES: Final[dict[str, Pubkey]] = {
"sol": WSOL_MINT,
"wsol": WSOL_MINT,
"usdc": USDC_MINT,
}
def resolve_quote_mint(value: str | Pubkey) -> Pubkey:
"""Resolve a config value into a quote mint address.
Accepts the aliases "sol"/"wsol"/"usdc" or a raw base58 mint address.
Args:
value: Alias or mint address from configuration
Returns:
Resolved quote mint
Raises:
ValueError: If the value is neither a known alias nor a valid address
"""
if isinstance(value, Pubkey):
return normalize_quote_mint(value)
alias = QUOTE_MINT_ALIASES.get(str(value).strip().lower())
if alias is not None:
return alias
try:
return normalize_quote_mint(Pubkey.from_string(str(value)))
except (ValueError, TypeError) as exc:
raise ValueError(
f"Unknown quote mint {value!r}. Use one of "
f"{sorted(QUOTE_MINT_ALIASES)} or a base58 mint address."
) from exc
def resolve_quote_amounts(
amounts: dict[str, float] | None,
) -> dict[Pubkey, float]:
"""Resolve a config map of quote mint -> spend amount.
Args:
amounts: Mapping of alias/mint address to amount in whole quote units
Returns:
Mapping keyed by resolved Pubkey (empty if amounts is None)
Raises:
ValueError: If a key is not a valid quote mint or an amount is not positive
"""
if not amounts:
return {}
resolved: dict[Pubkey, float] = {}
for key, amount in amounts.items():
mint = resolve_quote_mint(key)
if not isinstance(amount, int | float) or amount <= 0:
raise ValueError(
f"quote_amounts[{key!r}] must be a positive number, got {amount!r}"
)
resolved[mint] = float(amount)
return resolved
def is_sol_paired(quote_mint: Pubkey | None) -> bool:
"""Check whether a coin is SOL-paired (native SOL transfers).
Args:
quote_mint: Raw or normalized quote mint
Returns:
True if the coin trades against native/wrapped SOL
"""
return normalize_quote_mint(quote_mint) == WSOL_MINT
class SystemAddresses:
"""System-level Solana addresses shared across all platforms."""
@@ -49,6 +204,9 @@ class SystemAddresses:
ASSOCIATED_TOKEN_PROGRAM = ASSOCIATED_TOKEN_PROGRAM
RENT = RENT
SOL_MINT = SOL_MINT
WSOL_MINT = WSOL_MINT
USDC_MINT = USDC_MINT
DEFAULT_PUBKEY = DEFAULT_PUBKEY
@classmethod
def get_all_system_addresses(cls) -> dict[str, Pubkey]:
+7
View File
@@ -49,6 +49,13 @@ class TokenInfo:
is_mayhem_mode: bool = False # pump.fun mayhem mode flag
is_cashback_coin: bool = False # pump.fun cashback coin flag
# Quote asset (pump.fun v2 instructions). SOL-paired coins carry
# Pubkey::default() on-chain; normalize_quote_mint() maps that to wrapped
# SOL, which is what buy_v2/sell_v2 expect to be passed.
quote_mint: Pubkey | None = None
quote_token_program_id: Pubkey | None = None
virtual_quote_reserves: int | None = None
# Metadata
creation_timestamp: float | None = None
additional_data: dict[str, Any] | None = None
+9 -1
View File
@@ -18,6 +18,12 @@ from utils.logger import get_logger
logger = get_logger(__name__)
# Solana logsSubscribe / blockSubscribe frames routinely exceed the websockets
# library's 1 MiB default, which closes the connection with code 1009
# ("message too big"). Reconnecting recovers, but every dropped frame is a
# missed token, so raise the ceiling instead of eating the disconnects.
WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
class UniversalBlockListener(BaseTokenListener):
"""Universal block listener that works with any platform."""
@@ -97,7 +103,9 @@ class UniversalBlockListener(BaseTokenListener):
while True:
try:
async with websockets.connect(self.wss_endpoint) as websocket:
async with websockets.connect(
self.wss_endpoint, max_size=WEBSOCKET_MAX_MESSAGE_BYTES
) as websocket:
await self._subscribe_to_programs(websocket)
ping_task = asyncio.create_task(self._ping_loop(websocket))
+9 -1
View File
@@ -14,6 +14,12 @@ from utils.logger import get_logger
logger = get_logger(__name__)
# Solana logsSubscribe / blockSubscribe frames routinely exceed the websockets
# library's 1 MiB default, which closes the connection with code 1009
# ("message too big"). Reconnecting recovers, but every dropped frame is a
# missed token, so raise the ceiling instead of eating the disconnects.
WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
class UniversalLogsListener(BaseTokenListener):
"""Universal logs listener that works with any platform."""
@@ -96,7 +102,9 @@ class UniversalLogsListener(BaseTokenListener):
while True:
try:
async with websockets.connect(self.wss_endpoint) as websocket:
async with websockets.connect(
self.wss_endpoint, max_size=WEBSOCKET_MAX_MESSAGE_BYTES
) as websocket:
await self._subscribe_to_logs(websocket)
ping_task = asyncio.create_task(self._ping_loop(websocket))
+217 -10
View File
@@ -5,13 +5,14 @@ This module provides all pump.fun-specific addresses and PDA derivations
by implementing the AddressProvider interface.
"""
import secrets
from dataclasses import dataclass
from typing import ClassVar, Final
from solders.pubkey import Pubkey
from spl.token.instructions import get_associated_token_address
from core.pubkeys import SystemAddresses
from core.pubkeys import SystemAddresses, normalize_quote_mint, quote_token_program
from interfaces.core import AddressProvider, Platform, TokenInfo
@@ -43,10 +44,35 @@ class PumpFunAddresses:
FEE_PROGRAM: Final[Pubkey] = Pubkey.from_string(
"pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ"
)
# 8 breaking-upgrade fee recipients (pump.fun program upgrade 2026-04-28).
# One must be appended (mutable) AFTER bonding-curve-v2 on every buy/sell.
# Doc: github.com/pump-fun/pump-public-docs/blob/main/docs/BREAKING_FEE_RECIPIENT.md
BREAKING_FEE_RECIPIENTS: ClassVar[list[Pubkey]] = [
# 8 normal fee recipients — use one as fee_recipient for non-mayhem coins.
# See FEE_RECIPIENTS.md in the pump-fun public docs repository.
NORMAL_FEE_RECIPIENTS: ClassVar[list[Pubkey]] = [
Pubkey.from_string("62qc2CNXwrYqQScmEdiZFFAnJR262PxWEuNQtxfafNgV"),
Pubkey.from_string("7VtfL8fvgNfhz17qKRMjzQEXgbdpnHHHQRh54R9jP2RJ"),
Pubkey.from_string("7hTckgnGnLQR6sdH7YkqFTAA7VwTfYFaZ6EhEsU3saCX"),
Pubkey.from_string("9rPYyANsfQZw3DnDmKE3YCQF5E8oD89UXoHn9JFEhJUz"),
Pubkey.from_string("AVmoTthdrX6tKt4nDjco2D775W2YK3sDhxPcMmzUAmTY"),
Pubkey.from_string("CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM"),
Pubkey.from_string("FWsW1xNtWscwNmKv6wVsU1iTzRN6wmmk3MjxRP5tT7hz"),
Pubkey.from_string("G5UZAVbAf46s7cKWoyKu8kYTip9DGTpbLZ2qa9Aq69dP"),
]
# 8 reserved fee recipients — use one as fee_recipient for mayhem coins.
RESERVED_FEE_RECIPIENTS: ClassVar[list[Pubkey]] = [
Pubkey.from_string("GesfTA3X2arioaHp8bbKdjG9vJtskViWACZoYvxp4twS"),
Pubkey.from_string("4budycTjhs9fD6xw62VBducVTNgMgJJ5BgtKq7mAZwn6"),
Pubkey.from_string("8SBKzEQU4nLSzcwF4a74F2iaUDQyTfjGndn6qUWBnrpR"),
Pubkey.from_string("4UQeTP1T39KZ9Sfxzo3WR5skgsaP6NZa87BAkuazLEKH"),
Pubkey.from_string("8sNeir4QsLsJdYpc9RZacohhK1Y5FLU3nC5LXgYB4aa6"),
Pubkey.from_string("Fh9HmeLNUMVCvejxCtCL2DbYaRyBFVJ5xrWkLnMH6fdk"),
Pubkey.from_string("463MEnMeGyJekNZFQSTUABBEbLnvMTALbT6ZmsxAbAdq"),
Pubkey.from_string("6AUH3WEHucYZyC61hqpqYUWVto5qA5hjHuNQ32GNnNxA"),
]
# 8 buyback fee recipients — one is required on every buy/sell, for every
# coin. On the legacy buy/sell these are appended (mutable) after
# bonding-curve-v2; on buy_v2/sell_v2 they are the buyback_fee_recipient
# account. Introduced by the 2026-04-28 program upgrade.
# See FEE_RECIPIENTS.md in the pump-fun public docs repository.
BUYBACK_FEE_RECIPIENTS: ClassVar[list[Pubkey]] = [
Pubkey.from_string("5YxQFdt3Tr9zJLvkFccqXVUwhdTWJQc1fFg2YPbxvxeD"),
Pubkey.from_string("9M4giFFMxmFGXtc3feFzRai56WbBqehoSeRE5GK7gf7"),
Pubkey.from_string("GXPFM2caqTtQYC2cJ5yJRi9VDkpsYZXzYdwYpGnLmtDL"),
@@ -56,16 +82,40 @@ class PumpFunAddresses:
Pubkey.from_string("5eHhjP8JaYkz83CWwvGU2uMUXefd3AazWGx4gpcuEEYD"),
Pubkey.from_string("A7hAgCzFw14fejgCp387JUJRMNyz4j89JKnhtKU8piqW"),
]
# Back-compat alias for the pre-v2 naming used by the legacy buy/sell path.
BREAKING_FEE_RECIPIENTS: ClassVar[list[Pubkey]] = BUYBACK_FEE_RECIPIENTS
@staticmethod
def pick_breaking_fee_recipient() -> Pubkey:
"""Pick one of the 8 breaking-upgrade fee recipients at random.
def pick_buyback_fee_recipient() -> Pubkey:
"""Pick one of the 8 buyback fee recipients at random.
Spreads load across recipients per pump.fun's recommendation.
"""
import random
return secrets.choice(PumpFunAddresses.BUYBACK_FEE_RECIPIENTS)
return random.choice(PumpFunAddresses.BREAKING_FEE_RECIPIENTS)
@staticmethod
def pick_breaking_fee_recipient() -> Pubkey:
"""Deprecated alias for :meth:`pick_buyback_fee_recipient`."""
return PumpFunAddresses.pick_buyback_fee_recipient()
@staticmethod
def find_sharing_config(base_mint: Pubkey) -> Pubkey:
"""Derive the creator-fee sharing config PDA for a coin.
Mandatory on buy_v2/sell_v2. Lives under the pump fees program, not
the pump program.
Args:
base_mint: Base token mint address
Returns:
Pubkey of the derived sharing config account
"""
derived_address, _ = Pubkey.find_program_address(
[b"sharing-config", bytes(base_mint)],
PumpFunAddresses.FEE_PROGRAM,
)
return derived_address
@staticmethod
def find_global_volume_accumulator() -> Pubkey:
@@ -237,7 +287,10 @@ class PumpFunAddressProvider(AddressProvider):
return accounts
def derive_associated_bonding_curve(
self, mint: Pubkey, bonding_curve: Pubkey, token_program_id: Pubkey | None = None
self,
mint: Pubkey,
bonding_curve: Pubkey,
token_program_id: Pubkey | None = None,
) -> Pubkey:
"""Derive the associated bonding curve (ATA of bonding curve for the token).
@@ -314,6 +367,160 @@ class PumpFunAddressProvider(AddressProvider):
"""
return PumpFunAddresses.find_fee_config()
def derive_sharing_config(self, base_mint: Pubkey) -> Pubkey:
"""Derive the creator-fee sharing config PDA for a coin.
Args:
base_mint: Base token mint address
Returns:
Sharing config address
"""
return PumpFunAddresses.find_sharing_config(base_mint)
def resolve_quote(self, token_info: TokenInfo) -> tuple[Pubkey, Pubkey]:
"""Resolve the quote mint and its token program for a coin.
SOL-paired coins store Pubkey::default() in bonding_curve.quote_mint
but must pass wrapped SOL to the v2 instructions.
Args:
token_info: Token information
Returns:
Tuple of (quote_mint, quote_token_program)
"""
quote_mint = normalize_quote_mint(token_info.quote_mint)
quote_program = token_info.quote_token_program_id or quote_token_program(
quote_mint
)
return quote_mint, quote_program
def derive_quote_token_account(
self, owner: Pubkey, quote_mint: Pubkey, quote_token_program_id: Pubkey
) -> Pubkey:
"""Derive an associated token account for the quote mint.
Args:
owner: Account that owns the ATA (may be a PDA)
quote_mint: Quote mint address
quote_token_program_id: Token program owning the quote mint
Returns:
Associated token account address
"""
return get_associated_token_address(owner, quote_mint, quote_token_program_id)
def _get_v2_common_accounts(
self, token_info: TokenInfo, user: Pubkey
) -> dict[str, Pubkey]:
"""Build the account set shared by buy_v2 and sell_v2.
Both instructions take the same 26 accounts; buy_v2 additionally takes
global_volume_accumulator. All accounts are mandatory — there are no
optional or conditional accounts on the v2 interface, regardless of
mayhem/cashback/quote-mint combination.
Args:
token_info: Token information
user: User's wallet address
Returns:
Dictionary of account addresses keyed by IDL account name
"""
additional_accounts = self.get_additional_accounts(token_info)
base_mint = token_info.mint
base_token_program = (
token_info.token_program_id or SystemAddresses.TOKEN_2022_PROGRAM
)
quote_mint, quote_program = self.resolve_quote(token_info)
bonding_curve = additional_accounts.get(
"bonding_curve", token_info.bonding_curve
)
creator_vault = additional_accounts.get(
"creator_vault", token_info.creator_vault
)
fee_recipient = self.get_fee_recipient(token_info)
buyback_fee_recipient = PumpFunAddresses.pick_buyback_fee_recipient()
user_volume_accumulator = self.derive_user_volume_accumulator(user)
return {
"global": PumpFunAddresses.GLOBAL,
"base_mint": base_mint,
"quote_mint": quote_mint,
"base_token_program": base_token_program,
"quote_token_program": quote_program,
"associated_token_program": SystemAddresses.ASSOCIATED_TOKEN_PROGRAM,
"fee_recipient": fee_recipient,
"associated_quote_fee_recipient": self.derive_quote_token_account(
fee_recipient, quote_mint, quote_program
),
"buyback_fee_recipient": buyback_fee_recipient,
"associated_quote_buyback_fee_recipient": self.derive_quote_token_account(
buyback_fee_recipient, quote_mint, quote_program
),
"bonding_curve": bonding_curve,
"associated_base_bonding_curve": additional_accounts.get(
"associated_bonding_curve", token_info.associated_bonding_curve
),
"associated_quote_bonding_curve": self.derive_quote_token_account(
bonding_curve, quote_mint, quote_program
),
"user": user,
"associated_base_user": self.derive_user_token_account(
user, base_mint, base_token_program
),
"associated_quote_user": self.derive_quote_token_account(
user, quote_mint, quote_program
),
"creator_vault": creator_vault,
"associated_creator_vault": self.derive_quote_token_account(
creator_vault, quote_mint, quote_program
),
"sharing_config": self.derive_sharing_config(base_mint),
"user_volume_accumulator": user_volume_accumulator,
"associated_user_volume_accumulator": self.derive_quote_token_account(
user_volume_accumulator, quote_mint, quote_program
),
"fee_config": self.derive_fee_config(),
"fee_program": PumpFunAddresses.FEE_PROGRAM,
"system_program": SystemAddresses.SYSTEM_PROGRAM,
"event_authority": PumpFunAddresses.EVENT_AUTHORITY,
"program": PumpFunAddresses.PROGRAM,
}
def get_buy_v2_instruction_accounts(
self, token_info: TokenInfo, user: Pubkey
) -> dict[str, Pubkey]:
"""Get all 27 accounts needed for a buy_v2 instruction.
Args:
token_info: Token information
user: User's wallet address
Returns:
Dictionary of account addresses for the buy_v2 instruction
"""
accounts = self._get_v2_common_accounts(token_info, user)
accounts["global_volume_accumulator"] = self.derive_global_volume_accumulator()
return accounts
def get_sell_v2_instruction_accounts(
self, token_info: TokenInfo, user: Pubkey
) -> dict[str, Pubkey]:
"""Get all 26 accounts needed for a sell_v2 instruction.
Args:
token_info: Token information
user: User's wallet address
Returns:
Dictionary of account addresses for the sell_v2 instruction
"""
return self._get_v2_common_accounts(token_info, user)
def get_fee_recipient(self, token_info: TokenInfo) -> Pubkey:
"""Get the correct fee recipient based on mayhem mode.
+51 -22
View File
@@ -10,7 +10,13 @@ from typing import Any
from solders.pubkey import Pubkey
from core.client import SolanaClient
from core.pubkeys import LAMPORTS_PER_SOL, TOKEN_DECIMALS
from core.pubkeys import (
LAMPORTS_PER_SOL,
TOKEN_DECIMALS,
is_sol_paired,
normalize_quote_mint,
quote_units_per_token,
)
from interfaces.core import CurveManager, Platform
from utils.idl_parser import IDLParser
from utils.logger import get_logger
@@ -75,20 +81,17 @@ class PumpFunCurveManager(CurveManager):
pool_address: Address of the bonding curve
Returns:
Current token price in SOL
Current token price denominated in the curve's quote asset
(SOL for SOL-paired coins, USDC for USDC-paired coins)
"""
pool_state = await self.get_pool_state(pool_address)
# Use virtual reserves for price calculation
virtual_token_reserves = pool_state["virtual_token_reserves"]
virtual_sol_reserves = pool_state["virtual_sol_reserves"]
if virtual_token_reserves <= 0:
if pool_state["virtual_token_reserves"] <= 0:
return 0.0
# Price = sol_reserves / token_reserves
price_lamports = virtual_sol_reserves / virtual_token_reserves
return price_lamports * (10**TOKEN_DECIMALS) / LAMPORTS_PER_SOL
# _decode_curve_state_with_idl already scales by the quote mint's
# decimals, so don't re-derive the price with a hardcoded 1e9 here.
return pool_state["price_per_token"]
async def calculate_buy_amount_out(
self, pool_address: Pubkey, amount_in: int
@@ -185,51 +188,77 @@ class PumpFunCurveManager(CurveManager):
if not decoded_curve_state:
raise ValueError("Failed to decode bonding curve state with IDL parser")
# Extract the fields we need for trading calculations
# Based on the BondingCurve structure from the IDL
# Extract the fields we need for trading calculations.
# The BondingCurve struct renamed its SOL fields to quote fields when
# pump.fun added non-SOL quote assets, and appended quote_mint. The
# old names are kept as aliases below so callers written against the
# SOL-only layout keep working for SOL-paired coins.
raw_quote_mint = decoded_curve_state.get("quote_mint")
quote_mint = normalize_quote_mint(
Pubkey.from_string(raw_quote_mint)
if isinstance(raw_quote_mint, str)
else raw_quote_mint
)
quote_unit = quote_units_per_token(quote_mint)
curve_data = {
"virtual_token_reserves": decoded_curve_state.get(
"virtual_token_reserves", 0
),
"virtual_sol_reserves": decoded_curve_state.get("virtual_sol_reserves", 0),
"virtual_quote_reserves": decoded_curve_state.get(
"virtual_quote_reserves", 0
),
"real_token_reserves": decoded_curve_state.get("real_token_reserves", 0),
"real_sol_reserves": decoded_curve_state.get("real_sol_reserves", 0),
"real_quote_reserves": decoded_curve_state.get("real_quote_reserves", 0),
"token_total_supply": decoded_curve_state.get("token_total_supply", 0),
"complete": decoded_curve_state.get("complete", False),
"creator": decoded_curve_state.get("creator", ""),
"is_mayhem_mode": decoded_curve_state.get("is_mayhem_mode", False),
"is_cashback_coin": decoded_curve_state.get("is_cashback_coin", False),
"quote_mint": quote_mint,
"is_sol_paired": is_sol_paired(quote_mint),
}
# Back-compat aliases for the pre-quote-mint field names.
curve_data["virtual_sol_reserves"] = curve_data["virtual_quote_reserves"]
curve_data["real_sol_reserves"] = curve_data["real_quote_reserves"]
# Calculate additional metrics
# Validate reserves are positive before calculating price
if curve_data["virtual_token_reserves"] <= 0:
raise ValueError(
f"Invalid virtual_token_reserves: {curve_data['virtual_token_reserves']} - cannot calculate price"
)
if curve_data["virtual_sol_reserves"] <= 0:
if curve_data["virtual_quote_reserves"] <= 0:
raise ValueError(
f"Invalid virtual_sol_reserves: {curve_data['virtual_sol_reserves']} - cannot calculate price"
f"Invalid virtual_quote_reserves: {curve_data['virtual_quote_reserves']} - cannot calculate price"
)
# Price is denominated in the curve's quote asset, so scale by that
# mint's decimals (1e9 for SOL, 1e6 for USDC) rather than assuming SOL.
curve_data["price_per_token"] = (
(curve_data["virtual_sol_reserves"] / curve_data["virtual_token_reserves"])
(
curve_data["virtual_quote_reserves"]
/ curve_data["virtual_token_reserves"]
)
* (10**TOKEN_DECIMALS)
/ LAMPORTS_PER_SOL
/ quote_unit
)
# Add convenience decimal fields
curve_data["token_reserves_decimal"] = (
curve_data["virtual_token_reserves"] / 10**TOKEN_DECIMALS
)
curve_data["sol_reserves_decimal"] = (
curve_data["virtual_sol_reserves"] / LAMPORTS_PER_SOL
curve_data["quote_reserves_decimal"] = (
curve_data["virtual_quote_reserves"] / quote_unit
)
curve_data["sol_reserves_decimal"] = curve_data["quote_reserves_decimal"]
logger.debug(
f"Decoded curve state: virtual_token_reserves={curve_data['virtual_token_reserves']}, "
f"virtual_sol_reserves={curve_data['virtual_sol_reserves']}, "
f"price={curve_data['price_per_token']:.8f} SOL"
f"virtual_quote_reserves={curve_data['virtual_quote_reserves']}, "
f"quote_mint={quote_mint}, "
f"price={curve_data['price_per_token']:.8f} quote/token"
)
return curve_data
+58 -1
View File
@@ -13,7 +13,11 @@ from typing import Any
from solders.pubkey import Pubkey
from solders.transaction import VersionedTransaction
from core.pubkeys import SystemAddresses
from core.pubkeys import (
SystemAddresses,
normalize_quote_mint,
quote_token_program,
)
from interfaces.core import EventParser, Platform, TokenInfo
from platforms.pumpfun.address_provider import PumpFunAddresses
from utils.idl_parser import IDLParser
@@ -21,6 +25,38 @@ from utils.logger import get_logger
logger = get_logger(__name__)
# Index of the optional `quote_mint` remaining account on create_v2. Accounts
# 1-16 are in the IDL; 17-19 are optional remaining accounts appended only when
# creating a coin with a non-native quote mint. See COIN_CREATION.md in the
# pump-fun public docs repository.
_CREATE_V2_QUOTE_MINT_ACCOUNT_INDEX = 16
# Length of a Solana public key in bytes.
PUBKEY_BYTE_LENGTH = 32
def _coerce_pubkey(value: object) -> Pubkey | None:
"""Coerce a decoded IDL pubkey field into a Pubkey.
Args:
value: Decoded field, which may be a str, Pubkey, bytes or None
Returns:
Pubkey, or None if the value cannot be interpreted
"""
if value is None:
return None
if isinstance(value, Pubkey):
return value
if isinstance(value, str):
try:
return Pubkey.from_string(value)
except (ValueError, TypeError):
return None
if isinstance(value, bytes | bytearray) and len(value) == PUBKEY_BYTE_LENGTH:
return Pubkey.from_bytes(bytes(value))
return None
class PumpFunEventParser(EventParser):
"""Pump.Fun implementation of EventParser interface with IDL-based event parsing."""
@@ -262,6 +298,14 @@ class PumpFunEventParser(EventParser):
f"✅ Successfully parsed CreateEvent for token: {fields.get('symbol', 'Unknown')}"
)
# CreateEvent gained quote_mint and virtual_quote_reserves
# when pump.fun added non-SOL quote assets. Carry them on
# TokenInfo so extreme_fast_mode — which skips the curve
# fetch — still knows which quote asset to trade against.
quote_mint = normalize_quote_mint(
_coerce_pubkey(fields.get("quote_mint"))
)
return TokenInfo(
name=fields["name"],
symbol=fields["symbol"],
@@ -276,6 +320,9 @@ class PumpFunEventParser(EventParser):
token_program_id=token_program_id,
is_mayhem_mode=fields.get("is_mayhem_mode", False),
is_cashback_coin=fields.get("is_cashback_enabled", False),
quote_mint=quote_mint,
quote_token_program_id=quote_token_program(quote_mint),
virtual_quote_reserves=fields.get("virtual_quote_reserves"),
creation_timestamp=monotonic(),
)
@@ -373,6 +420,14 @@ class PumpFunEventParser(EventParser):
else False
)
# create_v2 passes a non-native quote mint as optional remaining
# account 17 (index 16). Absent means the coin is SOL-paired.
quote_mint = normalize_quote_mint(
get_account_key(_CREATE_V2_QUOTE_MINT_ACCOUNT_INDEX)
if is_create_v2
else None
)
return TokenInfo(
name=args.get("name", ""),
symbol=args.get("symbol", ""),
@@ -387,6 +442,8 @@ class PumpFunEventParser(EventParser):
token_program_id=token_program_id,
is_mayhem_mode=bool(args.get("is_mayhem_mode", False)),
is_cashback_coin=is_cashback,
quote_mint=quote_mint,
quote_token_program_id=quote_token_program(quote_mint),
creation_timestamp=monotonic(),
)
+331 -7
View File
@@ -11,37 +11,119 @@ from solders.instruction import AccountMeta, Instruction
from solders.pubkey import Pubkey
from spl.token.instructions import create_idempotent_associated_token_account
from core.pubkeys import TOKEN_DECIMALS
from core.pubkeys import TOKEN_DECIMALS, is_sol_paired
from interfaces.core import AddressProvider, InstructionBuilder, Platform, TokenInfo
from utils.idl_parser import IDLParser
from utils.logger import get_logger
logger = get_logger(__name__)
# Account order for buy_v2 (27 accounts) and sell_v2 (26 accounts).
# Every account is mandatory and the order is identical for every coin type —
# SOL-paired or USDC-paired, mayhem or not, cashback or not. This is the whole
# point of the v2 interface. See BUY.md and SELL.md under docs/instructions in
# the pump-fun public docs repository.
_BUY_V2_ACCOUNTS: list[tuple[str, bool]] = [
("global", False),
("base_mint", False),
("quote_mint", False),
("base_token_program", False),
("quote_token_program", False),
("associated_token_program", False),
("fee_recipient", True),
("associated_quote_fee_recipient", True),
("buyback_fee_recipient", True),
("associated_quote_buyback_fee_recipient", True),
("bonding_curve", True),
("associated_base_bonding_curve", True),
("associated_quote_bonding_curve", True),
("user", True),
("associated_base_user", True),
("associated_quote_user", True),
("creator_vault", True),
("associated_creator_vault", True),
("sharing_config", False),
("global_volume_accumulator", False),
("user_volume_accumulator", True),
("associated_user_volume_accumulator", True),
("fee_config", False),
("fee_program", False),
("system_program", False),
("event_authority", False),
("program", False),
]
# sell_v2 is buy_v2 without global_volume_accumulator.
_SELL_V2_ACCOUNTS: list[tuple[str, bool]] = [
entry for entry in _BUY_V2_ACCOUNTS if entry[0] != "global_volume_accumulator"
]
class PumpFunInstructionBuilder(InstructionBuilder):
"""Pump.Fun implementation of InstructionBuilder interface with IDL-based discriminators."""
def __init__(self, idl_parser: IDLParser):
def __init__(self, idl_parser: IDLParser, *, use_legacy_instructions: bool = False):
"""Initialize pump.fun instruction builder with injected IDL parser.
Args:
idl_parser: Pre-loaded IDL parser for pump.fun platform
use_legacy_instructions: Build the pre-v2 buy/sell instructions
instead of buy_v2/sell_v2. Legacy instructions cannot trade
coins paired with anything other than SOL.
"""
self._idl_parser = idl_parser
self._use_legacy_instructions = use_legacy_instructions
# Get discriminators from injected IDL parser
discriminators = self._idl_parser.get_instruction_discriminators()
self._buy_discriminator = discriminators["buy"]
self._sell_discriminator = discriminators["sell"]
self._buy_v2_discriminator = discriminators["buy_v2"]
self._sell_v2_discriminator = discriminators["sell_v2"]
logger.info("Pump.Fun instruction builder initialized with injected IDL parser")
logger.info(
"Pump.Fun instruction builder initialized with injected IDL parser "
f"(instruction set: {'legacy' if use_legacy_instructions else 'v2'})"
)
@property
def platform(self) -> Platform:
"""Get the platform this builder serves."""
return Platform.PUMP_FUN
@staticmethod
def _build_account_metas(
layout: list[tuple[str, bool]],
accounts_info: dict[str, Pubkey],
signer: Pubkey,
) -> list[AccountMeta]:
"""Turn a v2 account layout into ordered AccountMetas.
Args:
layout: Ordered (account name, is_writable) pairs
accounts_info: Resolved account addresses keyed by IDL name
signer: The account that signs the transaction
Returns:
Ordered list of AccountMeta
Raises:
KeyError: If the address provider did not supply a required account
"""
metas = []
for name, is_writable in layout:
pubkey = accounts_info[name]
if pubkey is None:
raise KeyError(f"Missing required account for v2 instruction: {name}")
metas.append(
AccountMeta(
pubkey=pubkey,
is_signer=pubkey == signer,
is_writable=is_writable,
)
)
return metas
async def build_buy_instruction(
self,
token_info: TokenInfo,
@@ -52,6 +134,169 @@ class PumpFunInstructionBuilder(InstructionBuilder):
) -> list[Instruction]:
"""Build buy instruction(s) for pump.fun.
Dispatches to buy_v2 unless the builder was constructed with
``use_legacy_instructions=True``.
Args:
token_info: Token information
user: User's wallet address
amount_in: Maximum quote amount to spend (raw quote units)
minimum_amount_out: Minimum tokens expected (raw token units)
address_provider: Platform address provider
Returns:
List of instructions needed for the buy operation
"""
if not self._use_legacy_instructions:
return await self.build_buy_v2_instruction(
token_info, user, amount_in, minimum_amount_out, address_provider
)
return await self.build_buy_legacy_instruction(
token_info, user, amount_in, minimum_amount_out, address_provider
)
async def build_buy_v2_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build a buy_v2 instruction plus the ATAs it needs.
Args:
token_info: Token information
user: User's wallet address
amount_in: Maximum quote amount to spend (raw quote units:
lamports for SOL-paired coins, 1e-6 USDC for USDC-paired)
minimum_amount_out: Base tokens to buy (raw token units)
address_provider: Platform address provider
Returns:
List of instructions needed for the buy operation
"""
instructions = []
accounts_info = address_provider.get_buy_v2_instruction_accounts(
token_info, user
)
# Base-token ATA for the buyer. buy_v2 does not create this for us.
instructions.append(
create_idempotent_associated_token_account(
user,
user,
accounts_info["base_mint"],
accounts_info["base_token_program"],
)
)
# Quote ATA for the buyer. For SOL-paired coins the program transfers
# native SOL and only seed-checks this account, so creating it would
# burn ~0.002 SOL of rent for nothing. Non-SOL quotes need a real,
# funded token account.
if not is_sol_paired(accounts_info["quote_mint"]):
instructions.append(
create_idempotent_associated_token_account(
user,
user,
accounts_info["quote_mint"],
accounts_info["quote_token_program"],
)
)
# buy_v2 args: amount (base tokens out), max_sol_cost (max quote in).
# Unlike legacy buy there is no track_volume OptionBool — volume
# tracking is unconditional now that user_volume_accumulator is
# mandatory.
instruction_data = (
self._buy_v2_discriminator
+ struct.pack("<Q", minimum_amount_out)
+ struct.pack("<Q", amount_in)
)
instructions.append(
Instruction(
program_id=accounts_info["program"],
data=instruction_data,
accounts=self._build_account_metas(
_BUY_V2_ACCOUNTS, accounts_info, user
),
)
)
return instructions
async def build_sell_v2_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build a sell_v2 instruction plus the ATAs it needs.
Args:
token_info: Token information
user: User's wallet address
amount_in: Base tokens to sell (raw token units)
minimum_amount_out: Minimum quote amount to receive (raw quote units)
address_provider: Platform address provider
Returns:
List of instructions needed for the sell operation
"""
instructions = []
accounts_info = address_provider.get_sell_v2_instruction_accounts(
token_info, user
)
# Proceeds of a non-SOL sale land in the seller's quote ATA, which must
# exist. SOL-paired sales pay out in native SOL.
if not is_sol_paired(accounts_info["quote_mint"]):
instructions.append(
create_idempotent_associated_token_account(
user,
user,
accounts_info["quote_mint"],
accounts_info["quote_token_program"],
)
)
# sell_v2 args: amount (base tokens in), min_sol_output (min quote out).
instruction_data = (
self._sell_v2_discriminator
+ struct.pack("<Q", amount_in)
+ struct.pack("<Q", minimum_amount_out)
)
instructions.append(
Instruction(
program_id=accounts_info["program"],
data=instruction_data,
accounts=self._build_account_metas(
_SELL_V2_ACCOUNTS, accounts_info, user
),
)
)
return instructions
async def build_buy_legacy_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build the pre-v2 18-account buy instruction.
Only works for SOL-paired coins.
Args:
token_info: Token information
user: User's wallet address
@@ -186,6 +431,39 @@ class PumpFunInstructionBuilder(InstructionBuilder):
) -> list[Instruction]:
"""Build sell instruction(s) for pump.fun.
Dispatches to sell_v2 unless the builder was constructed with
``use_legacy_instructions=True``.
Args:
token_info: Token information
user: User's wallet address
amount_in: Amount of tokens to sell (raw token units)
minimum_amount_out: Minimum quote amount expected (raw quote units)
address_provider: Platform address provider
Returns:
List of instructions needed for the sell operation
"""
if not self._use_legacy_instructions:
return await self.build_sell_v2_instruction(
token_info, user, amount_in, minimum_amount_out, address_provider
)
return await self.build_sell_legacy_instruction(
token_info, user, amount_in, minimum_amount_out, address_provider
)
async def build_sell_legacy_instruction(
self,
token_info: TokenInfo,
user: Pubkey,
amount_in: int,
minimum_amount_out: int,
address_provider: AddressProvider,
) -> list[Instruction]:
"""Build the pre-v2 16/17-account sell instruction.
Only works for SOL-paired coins.
Args:
token_info: Token information
user: User's wallet address
@@ -318,6 +596,12 @@ class PumpFunInstructionBuilder(InstructionBuilder):
Returns:
List of account addresses that will be accessed
"""
if not self._use_legacy_instructions:
accounts_info = address_provider.get_buy_v2_instruction_accounts(
token_info, user
)
return self._writable_accounts(_BUY_V2_ACCOUNTS, accounts_info)
accounts_info = address_provider.get_buy_instruction_accounts(token_info, user)
return [
@@ -336,6 +620,28 @@ class PumpFunInstructionBuilder(InstructionBuilder):
accounts_info["breaking_fee_recipient"],
]
@staticmethod
def _writable_accounts(
layout: list[tuple[str, bool]], accounts_info: dict[str, Pubkey]
) -> list[Pubkey]:
"""Collect the writable accounts from a v2 layout.
getRecentPrioritizationFees is only meaningful for accounts that are
write-locked, so program ids and sysvars are dropped.
Args:
layout: Ordered (account name, is_writable) pairs
accounts_info: Resolved account addresses keyed by IDL name
Returns:
List of writable account addresses
"""
return [
accounts_info[name]
for name, is_writable in layout
if is_writable and accounts_info.get(name) is not None
]
def get_required_accounts_for_sell(
self, token_info: TokenInfo, user: Pubkey, address_provider: AddressProvider
) -> list[Pubkey]:
@@ -349,6 +655,12 @@ class PumpFunInstructionBuilder(InstructionBuilder):
Returns:
List of account addresses that will be accessed
"""
if not self._use_legacy_instructions:
accounts_info = address_provider.get_sell_v2_instruction_accounts(
token_info, user
)
return self._writable_accounts(_SELL_V2_ACCOUNTS, accounts_info)
accounts_info = address_provider.get_sell_instruction_accounts(token_info, user)
return [
@@ -398,8 +710,15 @@ class PumpFunInstructionBuilder(InstructionBuilder):
"""
if config_override is not None:
return config_override
# Buy operations: ATA creation + buy instruction
return 100_000
if self._use_legacy_instructions:
# Buy operations: ATA creation + buy instruction
return 100_000
# buy_v2 touches 27 accounts, so it costs more than the legacy
# 18-account buy. Mainnet simulation of a SOL-paired Token-2022 buy
# (including base ATA creation) consumed ~125k CU; a non-SOL quote adds
# another ATA init on top. Re-measure with
# learning-examples/simulate_v2_trades.py after any program upgrade.
return 180_000
def get_sell_compute_unit_limit(self, config_override: int | None = None) -> int:
"""Get the recommended compute unit limit for pump.fun sell operations.
@@ -412,5 +731,10 @@ class PumpFunInstructionBuilder(InstructionBuilder):
"""
if config_override is not None:
return config_override
# Sell operations: typically just sell instruction (ATA exists)
return 60_000
if self._use_legacy_instructions:
# Sell operations: typically just sell instruction (ATA exists)
return 60_000
# sell_v2 touches 26 accounts. Measured at ~85k CU by simulating a
# buy+sell in one transaction on mainnet (the combined tx consumed
# ~211k against ~126k for the buy alone).
return 120_000
+208 -51
View File
@@ -9,7 +9,13 @@ from solders.pubkey import Pubkey
from core.client import SolanaClient
from core.priority_fee.manager import PriorityFeeManager
from core.pubkeys import LAMPORTS_PER_SOL, TOKEN_DECIMALS
from core.pubkeys import (
TOKEN_DECIMALS,
WSOL_MINT,
is_sol_paired,
normalize_quote_mint,
quote_units_per_token,
)
from core.wallet import Wallet
from interfaces.core import AddressProvider, Platform, TokenInfo
from platforms import get_platform_implementations
@@ -19,6 +25,79 @@ from utils.logger import get_logger
logger = get_logger(__name__)
def _quote_symbol(quote_mint: Pubkey) -> str:
"""Human-readable label for a quote mint, for logging only.
Args:
quote_mint: Quote mint address
Returns:
"SOL" for wrapped SOL, otherwise a truncated mint address
"""
if is_sol_paired(quote_mint):
return "SOL"
mint_str = str(quote_mint)
return f"{mint_str[:4]}..{mint_str[-4:]}"
async def _read_pool_state_with_retry(
curve_manager: object,
pool_address: Pubkey,
attempts: int = 4,
delay_seconds: float = 0.15,
) -> dict:
"""Read bonding curve state, retrying briefly on a lagging RPC node.
A freshly created curve may not be visible at `confirmed` yet, and a node
can momentarily serve a slot that predates it — both surface as "account
not found". Reading at `processed` and retrying a few times costs a handful
of RPC calls, which is far cheaper than trading on stale account data.
Args:
curve_manager: Platform curve manager
pool_address: Bonding curve / pool address
attempts: How many reads to try before giving up
delay_seconds: Pause between attempts
Returns:
Decoded pool state
Raises:
Exception: The last read error if every attempt fails
"""
last_error: Exception | None = None
for _ in range(attempts):
try:
return await curve_manager.get_pool_state(
pool_address, commitment="processed"
)
except Exception as error: # noqa: BLE001
last_error = error
await asyncio.sleep(delay_seconds)
raise last_error or RuntimeError("pool_state unavailable after retries")
def _refresh_quote_mint(token_info: TokenInfo, pool_state: dict) -> Pubkey:
"""Sync token_info's quote asset from freshly-read curve state.
Listeners do not all carry quote_mint (pumpportal carries none of the
per-coin flags), and the curve is authoritative, so prefer its value.
Args:
token_info: Token information, mutated in place
pool_state: Decoded bonding curve state
Returns:
The resolved quote mint
"""
quote_mint = normalize_quote_mint(
pool_state.get("quote_mint", token_info.quote_mint)
)
token_info.quote_mint = quote_mint
return quote_mint
class PlatformAwareBuyer(Trader):
"""Platform-aware token buyer that works with any supported platform."""
@@ -33,8 +112,25 @@ class PlatformAwareBuyer(Trader):
extreme_fast_token_amount: int = 0,
extreme_fast_mode: bool = False,
compute_units: dict | None = None,
quote_amounts: dict[Pubkey, float] | None = None,
):
"""Initialize platform-aware token buyer."""
"""Initialize platform-aware token buyer.
Args:
client: Solana RPC client
wallet: Trading wallet
priority_fee_manager: Priority fee strategy
amount: Amount of SOL to spend per buy on SOL-paired coins
slippage: Acceptable price deviation
max_retries: Transaction submission attempts
extreme_fast_token_amount: Tokens to buy when skipping price checks
extreme_fast_mode: Skip curve stabilization and price check
compute_units: Optional CU overrides
quote_amounts: Per-quote-mint spend amounts in whole quote units,
for coins paired against something other than SOL. A coin whose
quote mint is absent from this map is skipped rather than
traded with a SOL-denominated amount.
"""
self.client = client
self.wallet = wallet
self.priority_fee_manager = priority_fee_manager
@@ -44,6 +140,23 @@ class PlatformAwareBuyer(Trader):
self.extreme_fast_mode = extreme_fast_mode
self.extreme_fast_token_amount = extreme_fast_token_amount
self.compute_units = compute_units or {}
# SOL-paired coins always use `amount`; other quotes need an explicit
# per-mint amount because 0.0001 USDC and 0.0001 SOL are not comparable.
self.quote_amounts: dict[Pubkey, float] = {
WSOL_MINT: amount,
**(quote_amounts or {}),
}
def _resolve_quote_amount(self, quote_mint: Pubkey) -> float | None:
"""Get the configured spend amount for a quote mint.
Args:
quote_mint: Normalized quote mint
Returns:
Amount in whole quote units, or None if this quote is not configured
"""
return self.quote_amounts.get(quote_mint)
async def execute(self, token_info: TokenInfo) -> TradeResult:
"""Execute buy operation using platform-specific implementations."""
@@ -56,13 +169,13 @@ class PlatformAwareBuyer(Trader):
instruction_builder = implementations.instruction_builder
curve_manager = implementations.curve_manager
# Convert amount to lamports
amount_lamports = int(self.amount * LAMPORTS_PER_SOL)
# Quote asset is resolved from the curve below; start from whatever
# the listener gave us so extreme_fast_mode has a usable default.
quote_mint = normalize_quote_mint(token_info.quote_mint)
if self.extreme_fast_mode:
# Skip the wait and directly calculate the amount
token_amount = self.extreme_fast_token_amount
token_price_sol = self.amount / token_amount if token_amount > 0 else 0
# Skip the price check; the token count is fixed by config and
# sizing is finished once the quote amount is resolved below.
# Even in extreme_fast_mode, refresh mayhem/cashback/creator from
# chain — listeners (especially pumpportal) often don't carry
# these, and the program rejects with NotAuthorized (0x1770) /
@@ -71,34 +184,23 @@ class PlatformAwareBuyer(Trader):
# readable, so retry briefly. One handful of RPC calls is cheap
# relative to a failed buy.
try:
pool_address = self._get_pool_address(
token_info, address_provider
pool_address = self._get_pool_address(token_info, address_provider)
# Geyser/logs fire on processed, so the BC is typically
# readable in the same slot; pumpportal occasionally races
# the on-chain commit, hence the retries.
pool_state = await _read_pool_state_with_retry(
curve_manager, pool_address
)
pool_state = None
last_err: Exception | None = None
# Use processed commitment — geyser/logs fire on processed
# so the BC is typically readable in the same slot. Most
# listeners only need 1 attempt; pumpportal occasionally
# races the on-chain commit, so allow a few quick retries.
for attempt in range(4):
try:
pool_state = await curve_manager.get_pool_state(
pool_address, commitment="processed"
)
break
except Exception as inner: # noqa: BLE001
last_err = inner
await asyncio.sleep(0.15)
if pool_state is None:
raise last_err or RuntimeError(
"pool_state unavailable after retries"
)
token_info.is_mayhem_mode = pool_state.get(
"is_mayhem_mode", token_info.is_mayhem_mode
)
token_info.is_cashback_coin = pool_state.get(
"is_cashback_coin", token_info.is_cashback_coin
)
# The quote asset decides which balance we spend and how
# amounts are scaled, so it must come from the curve rather
# than a listener guess.
quote_mint = _refresh_quote_mint(token_info, pool_state)
fresh_creator = pool_state.get("creator")
if fresh_creator and hasattr(
address_provider, "derive_creator_vault"
@@ -142,20 +244,48 @@ class PlatformAwareBuyer(Trader):
token_info.is_cashback_coin = pool_state.get(
"is_cashback_coin", token_info.is_cashback_coin
)
token_amount = self.amount / token_price_sol
quote_mint = _refresh_quote_mint(token_info, pool_state)
# A coin paired against a quote asset we have no configured amount
# for cannot be traded — spending `amount` of it would be a
# different order of magnitude entirely.
quote_amount = self._resolve_quote_amount(quote_mint)
if quote_amount is None:
return TradeResult(
success=False,
platform=token_info.platform,
error_message=(
f"No configured buy amount for quote mint {quote_mint}; "
f"set trade.quote_amounts for this mint to trade it"
),
)
quote_unit = quote_units_per_token(quote_mint)
quote_label = _quote_symbol(quote_mint)
# Both branches need the resolved quote amount to finish sizing the
# trade: extreme_fast_mode fixes the token count and back-derives an
# implied price, while the regular path fixes the spend and derives
# the token count from the curve price.
if self.extreme_fast_mode:
token_amount = self.extreme_fast_token_amount
token_price_sol = quote_amount / token_amount if token_amount > 0 else 0
else:
token_amount = quote_amount / token_price_sol
# Calculate minimum token amount with slippage
minimum_token_amount = token_amount * (1 - self.slippage)
minimum_token_amount_raw = int(minimum_token_amount * 10**TOKEN_DECIMALS)
# Calculate maximum SOL to spend with slippage
max_amount_lamports = int(amount_lamports * (1 + self.slippage))
# Calculate maximum quote to spend with slippage, in the quote
# mint's own raw units (lamports for SOL, 1e-6 for USDC).
max_quote_amount_raw = int(quote_amount * quote_unit * (1 + self.slippage))
# Build buy instructions using platform-specific builder
instructions = await instruction_builder.build_buy_instruction(
token_info,
self.wallet.pubkey,
max_amount_lamports, # amount_in (SOL)
max_quote_amount_raw, # amount_in (raw quote units)
minimum_token_amount_raw, # minimum_amount_out (tokens)
address_provider,
)
@@ -166,10 +296,12 @@ class PlatformAwareBuyer(Trader):
)
logger.info(
f"Buying {token_amount:.6f} tokens at {token_price_sol:.8f} SOL per token on {token_info.platform.value}"
f"Buying {token_amount:.6f} tokens at {token_price_sol:.8f} "
f"{quote_label} per token on {token_info.platform.value}"
)
logger.info(
f"Total cost: {self.amount:.6f} SOL (max: {max_amount_lamports / LAMPORTS_PER_SOL:.6f} SOL)"
f"Total cost: {quote_amount:.6f} {quote_label} "
f"(max: {max_quote_amount_raw / quote_unit:.6f} {quote_label})"
)
# Send transaction
@@ -199,27 +331,33 @@ class PlatformAwareBuyer(Trader):
sol_destination = self._get_sol_destination(
token_info, address_provider
)
tokens_raw, sol_spent = await self.client.get_buy_transaction_details(
str(tx_signature), token_info.mint, sol_destination
tokens_raw, quote_spent = await self.client.get_buy_transaction_details(
str(tx_signature),
token_info.mint,
sol_destination,
quote_mint=quote_mint,
)
if tokens_raw is not None and sol_spent is not None:
if tokens_raw is not None and quote_spent is not None:
actual_amount = tokens_raw / 10**TOKEN_DECIMALS
actual_price = (sol_spent / LAMPORTS_PER_SOL) / actual_amount
actual_price = (quote_spent / quote_unit) / actual_amount
logger.info(
f"Actual tokens received: {actual_amount:.6f} "
f"(expected: {token_amount:.6f})"
)
logger.info(
f"Actual SOL spent: {sol_spent / LAMPORTS_PER_SOL:.10f} SOL"
f"Actual {quote_label} spent: "
f"{quote_spent / quote_unit:.10f} {quote_label}"
)
logger.info(
f"Actual price: {actual_price:.10f} {quote_label}/token"
)
logger.info(f"Actual price: {actual_price:.10f} SOL/token")
token_amount = actual_amount
token_price_sol = actual_price
else:
raise ValueError(
f"Failed to parse transaction details: tokens={tokens_raw}, "
f"sol_spent={sol_spent} (tx: {tx_signature}). "
f"quote_spent={quote_spent} (tx: {tx_signature}). "
f"The transaction may have failed on-chain — check explorer."
)
@@ -369,6 +507,9 @@ class PlatformAwareSeller(Trader):
instruction_builder = implementations.instruction_builder
curve_manager = implementations.curve_manager
# Fall back to the listener's quote asset if the refresh below fails.
quote_mint = normalize_quote_mint(token_info.quote_mint)
# Refresh mayhem-mode and cashback flags from curve state.
# The sell account list is 16 (non-cashback) vs 17 (cashback), and
# fee_recipient differs in mayhem mode — both can change between
@@ -376,13 +517,20 @@ class PlatformAwareSeller(Trader):
# flags carried in token_info.
try:
pool_address = self._get_pool_address(token_info, address_provider)
pool_state = await curve_manager.get_pool_state(pool_address)
# Retry rather than reading once at `confirmed`: a node serving a
# slightly stale slot reports the curve as missing, and silently
# falling back to create-time values risks a wrong creator_vault
# (ConstraintSeeds 0x7d6) or wrong mayhem fee_recipient.
pool_state = await _read_pool_state_with_retry(
curve_manager, pool_address
)
token_info.is_mayhem_mode = pool_state.get(
"is_mayhem_mode", token_info.is_mayhem_mode
)
token_info.is_cashback_coin = pool_state.get(
"is_cashback_coin", token_info.is_cashback_coin
)
quote_mint = _refresh_quote_mint(token_info, pool_state)
# Refresh creator/creator_vault from current BC state. Post
# 2026-04-28 the program may delegate BC.creator to a PFEE-owned
# PDA after the initial creator buy, so the create-time vault
@@ -408,13 +556,18 @@ class PlatformAwareSeller(Trader):
f"is_cashback_coin={token_info.is_cashback_coin}"
)
quote_unit = quote_units_per_token(quote_mint)
quote_label = _quote_symbol(quote_mint)
# Use pre-known amount and price (no RPC delay)
token_balance_decimal = token_amount
token_balance = int(token_amount * 10**TOKEN_DECIMALS)
token_price_sol = token_price
logger.info(f"Token balance: {token_balance_decimal:.6f}")
logger.info(f"Price per Token (from buy): {token_price_sol:.8f} SOL")
logger.info(
f"Price per Token (from buy): {token_price_sol:.8f} {quote_label}"
)
if token_balance == 0:
logger.info("No tokens to sell.")
@@ -424,19 +577,23 @@ class PlatformAwareSeller(Trader):
error_message="No tokens to sell",
)
# Calculate expected SOL output with slippage protection
expected_sol_output = token_balance_decimal * token_price_sol
min_sol_output = max(
# Calculate expected quote output with slippage protection, in the
# quote mint's raw units.
expected_quote_output = token_balance_decimal * token_price_sol
min_quote_output = max(
1,
int((expected_sol_output * (1 - self.slippage)) * LAMPORTS_PER_SOL),
int((expected_quote_output * (1 - self.slippage)) * quote_unit),
)
logger.info(
f"Selling {token_balance_decimal} tokens on {token_info.platform.value}"
)
logger.info(f"Expected SOL output: {expected_sol_output:.10f} SOL")
logger.info(
f"Minimum SOL output (with {self.slippage * 100:.1f}% slippage): "
f"{min_sol_output / LAMPORTS_PER_SOL:.10f} SOL ({min_sol_output} lamports)"
f"Expected {quote_label} output: {expected_quote_output:.10f} {quote_label}"
)
logger.info(
f"Minimum {quote_label} output (with {self.slippage * 100:.1f}% slippage): "
f"{min_quote_output / quote_unit:.10f} {quote_label} "
f"({min_quote_output} raw units)"
)
# Build sell instructions using platform-specific builder
@@ -444,7 +601,7 @@ class PlatformAwareSeller(Trader):
token_info,
self.wallet.pubkey,
token_balance, # amount_in (tokens)
min_sol_output, # minimum_amount_out (SOL)
min_quote_output, # minimum_amount_out (raw quote units)
address_provider,
)
+84 -21
View File
@@ -19,6 +19,12 @@ from cleanup.modes import (
)
from core.client import SolanaClient
from core.priority_fee.manager import PriorityFeeManager
from core.pubkeys import (
WSOL_MINT,
normalize_quote_mint,
resolve_quote_amounts,
resolve_quote_mint,
)
from core.wallet import Wallet
from interfaces.core import Platform, TokenInfo
from monitoring.listener_factory import ListenerFactory
@@ -46,6 +52,34 @@ except ImportError:
logger = get_logger(__name__)
def _resolve_quote_config(
buy_amount: float,
quote_amounts: dict[str, float] | None,
allowed_quote_mints: list[str] | None,
) -> tuple[dict[Pubkey, float], set[Pubkey] | None]:
"""Resolve quote-asset configuration into per-mint amounts and an allowlist.
Keys may be mint addresses or the aliases "sol"/"usdc". SOL always falls
back to trade.buy_amount, so a config that never mentions quote assets
keeps its existing SOL-only behaviour.
Args:
buy_amount: SOL amount per buy from trade.buy_amount
quote_amounts: Optional map of quote mint -> amount in whole units
allowed_quote_mints: Optional list of quote mints permitted to trade
Returns:
Tuple of (amount per quote mint, allowed quote mints or None for any)
"""
amounts = {WSOL_MINT: buy_amount, **resolve_quote_amounts(quote_amounts)}
allowed = (
{resolve_quote_mint(mint) for mint in allowed_quote_mints}
if allowed_quote_mints
else None
)
return amounts, allowed
class UniversalTrader:
"""Universal trading coordinator that works with any supported platform."""
@@ -68,6 +102,9 @@ class UniversalTrader:
# Trading configuration
extreme_fast_mode: bool = False,
extreme_fast_token_amount: int = 30,
# Quote asset configuration (pump.fun non-SOL pairs)
quote_amounts: dict[str, float] | None = None,
allowed_quote_mints: list[str] | None = None,
# Exit strategy configuration
exit_strategy: str = "time_based",
take_profit_percentage: float | None = None,
@@ -137,29 +174,34 @@ class UniversalTrader:
self.platform, self.solana_client
)
# Store compute unit configuration
# Store compute unit and quote-asset configuration
self.compute_units = compute_units or {}
# Create platform-aware traders
self.buyer = PlatformAwareBuyer(
self.solana_client,
self.wallet,
self.priority_fee_manager,
buy_amount,
buy_slippage,
max_retries,
extreme_fast_token_amount,
extreme_fast_mode,
compute_units=self.compute_units,
self.quote_amounts, self.allowed_quote_mints = _resolve_quote_config(
buy_amount, quote_amounts, allowed_quote_mints
)
self.seller = PlatformAwareSeller(
self.solana_client,
self.wallet,
self.priority_fee_manager,
sell_slippage,
max_retries,
compute_units=self.compute_units,
# Create platform-aware traders
self.buyer, self.seller = (
PlatformAwareBuyer(
self.solana_client,
self.wallet,
self.priority_fee_manager,
buy_amount,
buy_slippage,
max_retries,
extreme_fast_token_amount,
extreme_fast_mode,
compute_units=self.compute_units,
quote_amounts=self.quote_amounts,
),
PlatformAwareSeller(
self.solana_client,
self.wallet,
self.priority_fee_manager,
sell_slippage,
max_retries,
compute_units=self.compute_units,
),
)
# Initialize the appropriate listener with platform filtering
@@ -427,6 +469,25 @@ class UniversalTrader:
)
return
# Skip coins paired against a quote asset we are not set up to
# trade. Cheaper to drop here than to fail a buy on-chain.
token_quote_mint = normalize_quote_mint(token_info.quote_mint)
if (
self.allowed_quote_mints is not None
and token_quote_mint not in self.allowed_quote_mints
):
logger.info(
f"Skipping {token_info.symbol} - quote mint {token_quote_mint} "
f"not in allowed_quote_mints"
)
return
if token_quote_mint not in self.quote_amounts:
logger.info(
f"Skipping {token_info.symbol} - no buy amount configured for "
f"quote mint {token_quote_mint}"
)
return
# Wait for pool/curve to stabilize (unless in extreme fast mode)
if not self.extreme_fast_mode:
await self._save_token_info(token_info)
@@ -437,7 +498,9 @@ class UniversalTrader:
# Buy token
logger.info(
f"Buying {self.buy_amount:.6f} SOL worth of {token_info.symbol} on {token_info.platform.value}..."
f"Buying {self.quote_amounts[token_quote_mint]:.6f} of quote "
f"{token_quote_mint} worth of {token_info.symbol} "
f"on {token_info.platform.value}..."
)
buy_result: TradeResult = await self.buyer.execute(token_info)