mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-08-03 18:57:44 +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:
@@ -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
@@ -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})..."
|
||||
)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user