mirror of
https://github.com/chainstacklabs/pumpfun-bonkfun-bot.git
synced 2026-08-24 20:58:05 +00:00
* fix(trading): price tp/sl exits off the trigger price and retry a reverted sell `_monitor_position_until_exit` handed `position.entry_price` to the sell while the `current_price` that had just triggered the exit sat in the same scope, one line up. `PlatformAwareSeller.execute` does not read a price - the `token_price` it receives *is* the slippage floor - so a stop-loss priced off the entry demands more quote asset than the curve can pay and reverts with 6003 `TooLittleSolReceived`, during the very drop the stop-loss exists to escape. On a take-profit the same mistake runs the other way and the floor protects nothing. `current_price` costs no extra RPC call; `_handle_time_based_exit` has nothing fresher and keeps passing the buy price. The `break` also sat outside both branches of `if sell_result.success:`, so the loop exited whether the sell landed or not - contradicting the "Keep monitoring in case sell can be retried" comment directly above it. The seller's `max_retries` covers transaction submission only, so an on-chain revert was never retried and the position was abandoned mid-crash with `is_active=True`. A failed exit sell now retries on the next price check, re-reading the price so the floor tracks the market, bounded by `trade.max_exit_sell_attempts` (default 3, validated to 1..100) so a permanently reverting token cannot pin the bot on one position. The counter resets if the price recovers out of the exit band, and giving up is logged loudly since the tokens are still held. Fixes #189 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(learning-examples): machine-check the tp/sl exit price and retry path Drives the real `_monitor_position_until_exit` with a stub curve manager serving a scripted price series and a stub seller that records the price it is handed. Offline, no network and no funds moved, per the `verify_*` convention. Eight checks: both exit kinds sell at the triggering price, the entry-price floor is arithmetically unpayable on a drop while the trigger-price floor is payable, a reverted sell is retried and a landing retry closes the position, retries stay bounded, a price recovery resets the counter, a successful sell still closes on the first attempt, and the cap comes from `trade.max_exit_sell_attempts` wired through bot_runner and config_loader. Mutation-tested rather than trusted on a green run - reintroducing the stale entry price drops it to 4/8, giving up after one failure to 4/8, and ignoring the config knob to 7/8. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: document the tp/sl exit path and its verifier The caller owns the sell's slippage floor, which is not obvious from `PlatformAwareSeller.execute` - it never reads a price, it just turns `token_price` into `min_quote_output`. Records that, why an exit must price off the triggering price, and that the seller's `max_retries` covers submission only so an on-chain revert has to be retried in the monitor loop. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
820 lines
34 KiB
Python
820 lines
34 KiB
Python
"""
|
|
Platform-aware trader implementations that use the interface system.
|
|
Final cleanup removing all platform-specific hardcoding.
|
|
"""
|
|
|
|
import asyncio
|
|
from time import monotonic
|
|
|
|
from solders.pubkey import Pubkey
|
|
|
|
from core.client import SolanaClient
|
|
from core.priority_fee.manager import PriorityFeeManager
|
|
from core.pubkeys import (
|
|
TOKEN_DECIMALS,
|
|
WSOL_MINT,
|
|
SystemAddresses,
|
|
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
|
|
from trading.base import Trader, TradeResult
|
|
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,
|
|
mint: Pubkey | None = None,
|
|
budget_seconds: float = 2.0,
|
|
delay_seconds: float = 0.15,
|
|
) -> tuple[dict, Pubkey | None]:
|
|
"""Read bonding curve state, retrying within a time budget on a lagging 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 costs a handful of RPC
|
|
calls, which is far cheaper than trading on stale account data. Issue #170
|
|
measured individual reads on a load-balanced endpoint lagging several
|
|
seconds behind a fast listener, hence a time budget rather than a fixed
|
|
attempt count.
|
|
|
|
When `mint` is given and the curve manager supports it, the curve and the
|
|
mint are read in one slot-consistent batch so the mint's owning token
|
|
program comes back for free (pumpportal listeners can only guess it).
|
|
|
|
Args:
|
|
curve_manager: Platform curve manager
|
|
pool_address: Bonding curve / pool address
|
|
mint: Optional token mint to read alongside the curve
|
|
budget_seconds: Total time to keep retrying before giving up
|
|
delay_seconds: Pause between attempts
|
|
|
|
Returns:
|
|
Tuple of (decoded pool state, token program id or None if unknown)
|
|
|
|
Raises:
|
|
Exception: The last read error if every attempt fails
|
|
"""
|
|
batch_read = mint is not None and hasattr(
|
|
curve_manager, "get_pool_state_and_token_program"
|
|
)
|
|
deadline = monotonic() + budget_seconds
|
|
last_error: Exception | None = None
|
|
while True:
|
|
try:
|
|
if batch_read:
|
|
result = await curve_manager.get_pool_state_and_token_program(
|
|
pool_address, mint, commitment="processed"
|
|
)
|
|
else:
|
|
state = await curve_manager.get_pool_state(
|
|
pool_address, commitment="processed"
|
|
)
|
|
result = (state, None)
|
|
except Exception as error: # noqa: BLE001
|
|
last_error = error
|
|
if monotonic() + delay_seconds > deadline:
|
|
break
|
|
await asyncio.sleep(delay_seconds)
|
|
else:
|
|
return result
|
|
|
|
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."""
|
|
|
|
def __init__(
|
|
self,
|
|
client: SolanaClient,
|
|
wallet: Wallet,
|
|
priority_fee_manager: PriorityFeeManager,
|
|
amount: float,
|
|
slippage: float = 0.01,
|
|
max_retries: int = 5,
|
|
extreme_fast_token_amount: int = 0,
|
|
extreme_fast_mode: bool = False,
|
|
compute_units: dict | None = None,
|
|
quote_amounts: dict[Pubkey, float] | None = None,
|
|
curve_refresh_budget: float = 2.0,
|
|
*,
|
|
trust_create_event: bool = True,
|
|
):
|
|
"""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.
|
|
curve_refresh_budget: Seconds to keep retrying the pre-buy curve
|
|
read before skipping the token. A buy built without fresh curve
|
|
state guesses fee_recipient/creator_vault and tends to revert
|
|
on-chain (issue #170), so skipping beats racing.
|
|
trust_create_event: Skip the pre-buy curve read entirely for
|
|
TokenInfo marked state_from_event (creator/flags/quote_mint
|
|
read from the on-chain CreateEvent) — extreme_fast_mode then
|
|
makes zero RPC calls between detection and submission. Set
|
|
False to force the refresh for every listener.
|
|
"""
|
|
self.client = client
|
|
self.wallet = wallet
|
|
self.priority_fee_manager = priority_fee_manager
|
|
self.amount = amount
|
|
self.slippage = slippage
|
|
self.max_retries = max_retries
|
|
self.extreme_fast_mode = extreme_fast_mode
|
|
self.extreme_fast_token_amount = extreme_fast_token_amount
|
|
self.compute_units = compute_units or {}
|
|
self.curve_refresh_budget = curve_refresh_budget
|
|
self.trust_create_event = trust_create_event
|
|
# 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."""
|
|
try:
|
|
# Get platform-specific implementations
|
|
implementations = get_platform_implementations(
|
|
token_info.platform, self.client
|
|
)
|
|
address_provider = implementations.address_provider
|
|
instruction_builder = implementations.instruction_builder
|
|
curve_manager = implementations.curve_manager
|
|
|
|
# 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:
|
|
# Zero-RPC hot path — the point of extreme_fast_mode. When the
|
|
# CreateEvent already carried the canonical creator, the
|
|
# mayhem/cashback flags and quote_mint, nothing sits between
|
|
# detection and submission. Otherwise (pumpportal, old-format
|
|
# events) refresh from chain or skip.
|
|
if not self._can_skip_refresh(token_info):
|
|
skip_reason = await self._refresh_curve_state(
|
|
token_info, address_provider, curve_manager
|
|
)
|
|
if skip_reason is not None:
|
|
return TradeResult(
|
|
success=False,
|
|
platform=token_info.platform,
|
|
error_message=skip_reason,
|
|
)
|
|
quote_mint = normalize_quote_mint(token_info.quote_mint)
|
|
else:
|
|
# Get pool address based on platform using platform-agnostic method
|
|
pool_address = self._get_pool_address(token_info, address_provider)
|
|
|
|
# Regular behavior with RPC call
|
|
# Fetch pool state to get price and mayhem mode status
|
|
pool_state = await curve_manager.get_pool_state(pool_address)
|
|
token_price_sol = pool_state.get("price_per_token")
|
|
|
|
# Validate price_per_token is present and positive
|
|
if token_price_sol is None or token_price_sol <= 0:
|
|
raise ValueError(
|
|
f"Invalid price_per_token: {token_price_sol} for pool {pool_address} "
|
|
f"(mint: {token_info.mint}) - cannot execute buy with zero/invalid price"
|
|
)
|
|
|
|
# Set mayhem-mode and cashback flags from bonding-curve state
|
|
# so the instruction builder picks the correct fee_recipient and
|
|
# account-list shape (cashback sells use 17 accounts, non-cashback 16).
|
|
token_info.is_mayhem_mode = pool_state.get("is_mayhem_mode", False)
|
|
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)
|
|
|
|
# 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 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_quote_amount_raw, # amount_in (raw quote units)
|
|
minimum_token_amount_raw, # minimum_amount_out (tokens)
|
|
address_provider,
|
|
)
|
|
|
|
# Get accounts for priority fee calculation
|
|
priority_accounts = instruction_builder.get_required_accounts_for_buy(
|
|
token_info, self.wallet.pubkey, address_provider
|
|
)
|
|
|
|
logger.info(
|
|
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: {quote_amount:.6f} {quote_label} "
|
|
f"(max: {max_quote_amount_raw / quote_unit:.6f} {quote_label})"
|
|
)
|
|
|
|
# Send transaction
|
|
tx_signature = await self.client.build_and_send_transaction(
|
|
instructions,
|
|
self.wallet.keypair,
|
|
skip_preflight=True,
|
|
max_retries=self.max_retries,
|
|
priority_fee=await self.priority_fee_manager.calculate_priority_fee(
|
|
priority_accounts
|
|
),
|
|
compute_unit_limit=instruction_builder.get_buy_compute_unit_limit(
|
|
self._get_cu_override("buy", token_info.platform)
|
|
),
|
|
account_data_size_limit=self._get_cu_override(
|
|
"account_data_size", token_info.platform
|
|
),
|
|
)
|
|
|
|
success = await self.client.confirm_transaction(tx_signature)
|
|
|
|
if success:
|
|
logger.info(f"Buy transaction confirmed: {tx_signature}")
|
|
|
|
# Fetch actual tokens and SOL spent from transaction
|
|
# Uses preBalances/postBalances to get exact amounts
|
|
sol_destination = self._get_sol_destination(
|
|
token_info, address_provider
|
|
)
|
|
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 quote_spent is not None:
|
|
actual_amount = tokens_raw / 10**TOKEN_DECIMALS
|
|
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 {quote_label} spent: "
|
|
f"{quote_spent / quote_unit:.10f} {quote_label}"
|
|
)
|
|
logger.info(
|
|
f"Actual price: {actual_price:.10f} {quote_label}/token"
|
|
)
|
|
token_amount = actual_amount
|
|
token_price_sol = actual_price
|
|
else:
|
|
raise ValueError(
|
|
f"Failed to parse transaction details: tokens={tokens_raw}, "
|
|
f"quote_spent={quote_spent} (tx: {tx_signature}). "
|
|
f"The transaction may have failed on-chain — check explorer."
|
|
)
|
|
|
|
return TradeResult(
|
|
success=True,
|
|
platform=token_info.platform,
|
|
tx_signature=tx_signature,
|
|
amount=token_amount,
|
|
price=token_price_sol,
|
|
)
|
|
else:
|
|
return TradeResult(
|
|
success=False,
|
|
platform=token_info.platform,
|
|
error_message=f"Transaction failed to confirm: {tx_signature}",
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.exception("Buy operation failed")
|
|
return TradeResult(
|
|
success=False, platform=token_info.platform, error_message=str(e)
|
|
)
|
|
|
|
def _get_pool_address(
|
|
self, token_info: TokenInfo, address_provider: AddressProvider
|
|
) -> Pubkey:
|
|
"""Get the pool/curve address for price calculations using platform-agnostic method."""
|
|
# Try to get the address from token_info first, then derive if needed
|
|
if token_info.platform == Platform.PUMP_FUN:
|
|
if hasattr(token_info, "bonding_curve") and token_info.bonding_curve:
|
|
return token_info.bonding_curve
|
|
elif token_info.platform == Platform.LETS_BONK:
|
|
if hasattr(token_info, "pool_state") and token_info.pool_state:
|
|
return token_info.pool_state
|
|
|
|
# Fallback to deriving the address using platform provider
|
|
return address_provider.derive_pool_address(token_info.mint)
|
|
|
|
def _can_skip_refresh(self, token_info: TokenInfo) -> bool:
|
|
"""Whether the pre-buy curve read can be skipped entirely.
|
|
|
|
True when the listener read creator, mayhem/cashback and quote_mint
|
|
from the on-chain CreateEvent (canonical at create time), keeping
|
|
extreme_fast_mode at zero RPC calls between detection and submission.
|
|
|
|
Args:
|
|
token_info: Token information from the listener
|
|
|
|
Returns:
|
|
True if the buy can be built from token_info as-is
|
|
"""
|
|
return (
|
|
self.trust_create_event
|
|
and token_info.state_from_event
|
|
and token_info.quote_mint is not None
|
|
)
|
|
|
|
async def _refresh_curve_state(
|
|
self,
|
|
token_info: TokenInfo,
|
|
address_provider: AddressProvider,
|
|
curve_manager: object,
|
|
) -> str | None:
|
|
"""Refresh mayhem/cashback/creator/quote_mint/token program from chain.
|
|
|
|
Listeners that guess these (pumpportal carries none of them) produce
|
|
buys the program rejects with NotAuthorized (0x1770) / ConstraintSeeds
|
|
(0x7d6) when fee_recipient or creator_vault is wrong. PumpPortal also
|
|
notifies before the BC account is readable on a lagging node, so the
|
|
read retries within curve_refresh_budget.
|
|
|
|
Args:
|
|
token_info: Token information, mutated in place on success
|
|
address_provider: Platform address provider
|
|
curve_manager: Platform curve manager
|
|
|
|
Returns:
|
|
None on success; on failure a reason to skip the buy — a buy built
|
|
from listener-guessed defaults tends to revert on-chain
|
|
(issue #170: 0x1770 / 0x7d6 / pool 3012), which still costs the fee
|
|
"""
|
|
try:
|
|
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, fresh_token_program = await _read_pool_state_with_retry(
|
|
curve_manager,
|
|
pool_address,
|
|
mint=token_info.mint,
|
|
budget_seconds=self.curve_refresh_budget,
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
return (
|
|
f"Curve state unreadable within {self.curve_refresh_budget:.1f}s "
|
|
f"({e}); skipping buy rather than submitting with guessed accounts"
|
|
)
|
|
|
|
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.
|
|
_refresh_quote_mint(token_info, pool_state)
|
|
fresh_creator = pool_state.get("creator")
|
|
if fresh_creator and hasattr(address_provider, "derive_creator_vault"):
|
|
new_creator = (
|
|
Pubkey.from_string(fresh_creator)
|
|
if isinstance(fresh_creator, str)
|
|
else fresh_creator
|
|
)
|
|
token_info.creator = new_creator
|
|
token_info.creator_vault = address_provider.derive_creator_vault(
|
|
new_creator
|
|
)
|
|
self._apply_token_program(token_info, fresh_token_program, address_provider)
|
|
return None
|
|
|
|
def _apply_token_program(
|
|
self,
|
|
token_info: TokenInfo,
|
|
token_program: Pubkey | None,
|
|
address_provider: AddressProvider,
|
|
) -> None:
|
|
"""Correct a listener-guessed token program from the mint's real owner.
|
|
|
|
PumpPortal payloads carry no token program, so the processor defaults
|
|
to Token-2022; a legacy-`create` coin is SPL Token and the ATA-create
|
|
instruction then fails with IncorrectProgramId. The associated bonding
|
|
curve is an ordinary ATA, so it must be re-derived under the corrected
|
|
program too.
|
|
|
|
Args:
|
|
token_info: Token information, mutated in place
|
|
token_program: Owner of the mint account, or None if unknown
|
|
address_provider: Platform address provider for ATA derivation
|
|
"""
|
|
known_programs = (
|
|
SystemAddresses.TOKEN_PROGRAM,
|
|
SystemAddresses.TOKEN_2022_PROGRAM,
|
|
)
|
|
if token_program is None or token_program not in known_programs:
|
|
return
|
|
if token_info.token_program_id == token_program:
|
|
return
|
|
logger.info(
|
|
f"Correcting token program for {token_info.mint}: "
|
|
f"{token_info.token_program_id} -> {token_program}"
|
|
)
|
|
token_info.token_program_id = token_program
|
|
if token_info.bonding_curve and hasattr(
|
|
address_provider, "derive_associated_bonding_curve"
|
|
):
|
|
token_info.associated_bonding_curve = (
|
|
address_provider.derive_associated_bonding_curve(
|
|
token_info.mint, token_info.bonding_curve, token_program
|
|
)
|
|
)
|
|
|
|
def _get_sol_destination(
|
|
self, token_info: TokenInfo, address_provider: AddressProvider
|
|
) -> Pubkey:
|
|
"""Get the address where SOL is sent during a buy transaction.
|
|
|
|
For pump.fun: SOL goes to the bonding curve
|
|
For letsbonk: SOL goes to the quote_vault (WSOL vault)
|
|
|
|
Args:
|
|
token_info: Token information
|
|
address_provider: Platform-specific address provider
|
|
|
|
Returns:
|
|
Address where SOL is transferred during buy
|
|
|
|
Raises:
|
|
NotImplementedError: If platform SOL destination is not implemented
|
|
"""
|
|
if token_info.platform == Platform.PUMP_FUN:
|
|
# For pump.fun, SOL goes directly to bonding curve
|
|
if hasattr(token_info, "bonding_curve") and token_info.bonding_curve:
|
|
return token_info.bonding_curve
|
|
return address_provider.derive_pool_address(token_info.mint)
|
|
elif token_info.platform == Platform.LETS_BONK:
|
|
# For letsbonk, SOL goes to quote_vault (WSOL vault)
|
|
if hasattr(token_info, "quote_vault") and token_info.quote_vault:
|
|
return token_info.quote_vault
|
|
# Derive quote_vault if not available
|
|
return address_provider.derive_quote_vault(token_info.mint)
|
|
|
|
raise NotImplementedError(
|
|
f"SOL destination not implemented for platform {token_info.platform.value}. "
|
|
f"Add platform-specific logic to _get_sol_destination() to specify where "
|
|
f"SOL is transferred during buy transactions for this platform."
|
|
)
|
|
|
|
def _get_cu_override(self, operation: str, platform: Platform) -> int | None:
|
|
"""Get compute unit override from configuration.
|
|
|
|
Args:
|
|
operation: "buy" or "sell"
|
|
platform: Trading platform (unused - each config is platform-specific)
|
|
|
|
Returns:
|
|
CU override value if configured, None otherwise
|
|
"""
|
|
if not self.compute_units:
|
|
return None
|
|
|
|
# Just check for operation override (buy/sell)
|
|
return self.compute_units.get(operation)
|
|
|
|
|
|
class PlatformAwareSeller(Trader):
|
|
"""Platform-aware token seller that works with any supported platform."""
|
|
|
|
def __init__(
|
|
self,
|
|
client: SolanaClient,
|
|
wallet: Wallet,
|
|
priority_fee_manager: PriorityFeeManager,
|
|
slippage: float = 0.25,
|
|
max_retries: int = 5,
|
|
compute_units: dict | None = None,
|
|
):
|
|
"""Initialize platform-aware token seller."""
|
|
self.client = client
|
|
self.wallet = wallet
|
|
self.priority_fee_manager = priority_fee_manager
|
|
self.slippage = slippage
|
|
self.max_retries = max_retries
|
|
self.compute_units = compute_units or {}
|
|
|
|
async def execute(
|
|
self, token_info: TokenInfo, token_amount: float, token_price: float
|
|
) -> TradeResult:
|
|
"""Execute sell operation using platform-specific implementations.
|
|
|
|
Args:
|
|
token_info: Token information for the sell operation
|
|
token_amount: Token amount to sell (from buy result). Required to avoid
|
|
RPC balance query delays.
|
|
token_price: Reference price in the quote asset that the slippage
|
|
floor is computed from. Required rather than read here,
|
|
to avoid RPC pool state query delays — pass the freshest
|
|
price the caller has. A stale price that is above the
|
|
market sets a floor the pool cannot pay and the sell
|
|
reverts (pump.fun 6003 TooLittleSolReceived).
|
|
|
|
Returns:
|
|
TradeResult with operation outcome
|
|
|
|
Raises:
|
|
ValueError: If required parameters are not provided
|
|
"""
|
|
if token_amount is None:
|
|
raise ValueError(
|
|
"token_amount is required for sell operation. "
|
|
"Pass the amount from buy result to avoid RPC delays."
|
|
)
|
|
if token_price is None or token_price <= 0:
|
|
raise ValueError(
|
|
"token_price is required for sell operation and must be positive. "
|
|
"Pass the price from buy result to avoid RPC delays."
|
|
)
|
|
|
|
try:
|
|
# Get platform-specific implementations
|
|
implementations = get_platform_implementations(
|
|
token_info.platform, self.client
|
|
)
|
|
address_provider = implementations.address_provider
|
|
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
|
|
# buy and sell, so re-read from chain instead of trusting create-time
|
|
# flags carried in token_info.
|
|
try:
|
|
pool_address = self._get_pool_address(token_info, address_provider)
|
|
# 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
|
|
# cached on token_info goes stale before the sell lands. Failing
|
|
# to refresh manifests as ConstraintSeeds (0x7d6) on Sell.
|
|
fresh_creator = pool_state.get("creator")
|
|
if fresh_creator:
|
|
from solders.pubkey import Pubkey as _Pubkey
|
|
|
|
new_creator = (
|
|
_Pubkey.from_string(fresh_creator)
|
|
if isinstance(fresh_creator, str)
|
|
else fresh_creator
|
|
)
|
|
token_info.creator = new_creator
|
|
token_info.creator_vault = address_provider.derive_creator_vault(
|
|
new_creator
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning(
|
|
f"Could not refresh curve flags before sell ({e}); "
|
|
f"using token_info values is_mayhem_mode={token_info.is_mayhem_mode}, "
|
|
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"Reference price per token: {token_price_sol:.8f} {quote_label}"
|
|
)
|
|
|
|
if token_balance == 0:
|
|
logger.info("No tokens to sell.")
|
|
return TradeResult(
|
|
success=False,
|
|
platform=token_info.platform,
|
|
error_message="No tokens to sell",
|
|
)
|
|
|
|
# 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_quote_output * (1 - self.slippage)) * quote_unit),
|
|
)
|
|
logger.info(
|
|
f"Selling {token_balance_decimal} tokens on {token_info.platform.value}"
|
|
)
|
|
logger.info(
|
|
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
|
|
instructions = await instruction_builder.build_sell_instruction(
|
|
token_info,
|
|
self.wallet.pubkey,
|
|
token_balance, # amount_in (tokens)
|
|
min_quote_output, # minimum_amount_out (raw quote units)
|
|
address_provider,
|
|
)
|
|
|
|
# Get accounts for priority fee calculation
|
|
priority_accounts = instruction_builder.get_required_accounts_for_sell(
|
|
token_info, self.wallet.pubkey, address_provider
|
|
)
|
|
|
|
# Send transaction
|
|
tx_signature = await self.client.build_and_send_transaction(
|
|
instructions,
|
|
self.wallet.keypair,
|
|
skip_preflight=True,
|
|
max_retries=self.max_retries,
|
|
priority_fee=await self.priority_fee_manager.calculate_priority_fee(
|
|
priority_accounts
|
|
),
|
|
compute_unit_limit=instruction_builder.get_sell_compute_unit_limit(
|
|
self._get_cu_override("sell", token_info.platform)
|
|
),
|
|
account_data_size_limit=self._get_cu_override(
|
|
"account_data_size", token_info.platform
|
|
),
|
|
)
|
|
|
|
success = await self.client.confirm_transaction(tx_signature)
|
|
|
|
if success:
|
|
logger.info(f"Sell transaction confirmed: {tx_signature}")
|
|
return TradeResult(
|
|
success=True,
|
|
platform=token_info.platform,
|
|
tx_signature=tx_signature,
|
|
amount=token_balance_decimal,
|
|
price=token_price_sol,
|
|
)
|
|
else:
|
|
return TradeResult(
|
|
success=False,
|
|
platform=token_info.platform,
|
|
error_message=f"Transaction failed to confirm: {tx_signature}",
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.exception("Sell operation failed")
|
|
return TradeResult(
|
|
success=False, platform=token_info.platform, error_message=str(e)
|
|
)
|
|
|
|
def _get_pool_address(
|
|
self, token_info: TokenInfo, address_provider: AddressProvider
|
|
) -> Pubkey:
|
|
"""Get the pool/curve address for price calculations using platform-agnostic method."""
|
|
# Try to get the address from token_info first, then derive if needed
|
|
if token_info.platform == Platform.PUMP_FUN:
|
|
if hasattr(token_info, "bonding_curve") and token_info.bonding_curve:
|
|
return token_info.bonding_curve
|
|
elif token_info.platform == Platform.LETS_BONK:
|
|
if hasattr(token_info, "pool_state") and token_info.pool_state:
|
|
return token_info.pool_state
|
|
|
|
# Fallback to deriving the address using platform provider
|
|
return address_provider.derive_pool_address(token_info.mint)
|
|
|
|
def _get_cu_override(self, operation: str, platform: Platform) -> int | None:
|
|
"""Get compute unit override from configuration.
|
|
|
|
Args:
|
|
operation: "buy" or "sell"
|
|
platform: Trading platform (unused - each config is platform-specific)
|
|
|
|
Returns:
|
|
CU override value if configured, None otherwise
|
|
"""
|
|
if not self.compute_units:
|
|
return None
|
|
|
|
# Just check for operation override (buy/sell)
|
|
return self.compute_units.get(operation)
|