mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-08-25 05:08:04 +00:00
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:
co-authored by
Claude Opus 5
parent
3b88a06d9d
commit
02343b775b
+208
-51
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user