fix(trading): buy the whole token count on pump.fun (#236)

buy_v2 takes an exact token count and caps the quote cost. The trader passed
the slippage-reduced floor meant for exact-in programs as that count, so every
pump.fun buy received token_amount * (1 - slippage). Slippage now goes on the
cost ceiling only.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anton Sauchyk
2026-10-04 22:29:07 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d8a0ff7fe5
commit 457aeeb6d8
5 changed files with 180 additions and 12 deletions
+1
View File
@@ -28,6 +28,7 @@ individual scripts for a subset.
| `verify_curve_refresh_buy_path.py` | a TokenInfo without `state_from_event` refreshes first: an unreadable curve skips the buy, and curve + mint are read in one slot-consistent batch that corrects the token program |
| `verify_cleanup_survives_shutdown.py` | shutdown cleanup runs to completion through the cancellation that triggered it, bounded, and a cleanup that hangs or raises still lets the process exit |
| `verify_token_queue_shutdown.py` | cancelling the token queue processor ends it without raising, and `task_done()` stays balanced on the paths that took an item |
| `verify_pumpfun_buy_token_amount.py` | a pump.fun `buy_v2` asks for the whole sized token count with slippage on the cost ceiling, in extreme_fast_mode and on the regular path |
| `verify_buy_result_not_lost.py` | a landed buy is never reported failed, and a reverted one never reported landed |
| `verify_tx_status_checks.py` | every path reads `meta.err`; `--live` replays known reverted signatures |
| `verify_tp_sl_exit_price.py` | the tp/sl exit prices off the trigger price, and a reverted sell is retried, bounded |
+4 -2
View File
@@ -204,8 +204,10 @@ class InstructionBuilder(ABC):
Args:
user: User's wallet address
amount_in: Amount of quote tokens to spend
minimum_amount_out: Minimum base tokens expected
amount_in: Raw quote units: all of it is spent when
`spends_exact_amount_in`, otherwise it is the most to spend
minimum_amount_out: Raw base tokens: the floor that must arrive
when `spends_exact_amount_in`, otherwise the exact count to buy
address_provider: Platform address provider
Returns:
+4 -3
View File
@@ -133,7 +133,8 @@ class PumpFunInstructionBuilder(InstructionBuilder):
Args:
user: User's wallet address
amount_in: Maximum quote amount to spend (raw quote units)
minimum_amount_out: Minimum tokens expected (raw token units)
minimum_amount_out: Exact tokens to buy (raw token units); the
slippage cap is amount_in
address_provider: Platform address provider
Returns:
@@ -161,7 +162,7 @@ class PumpFunInstructionBuilder(InstructionBuilder):
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)
minimum_amount_out: Exact base tokens to buy (raw token units)
address_provider: Platform address provider
Returns:
@@ -290,7 +291,7 @@ class PumpFunInstructionBuilder(InstructionBuilder):
Args:
user: User's wallet address
amount_in: Amount of SOL to spend (in lamports)
minimum_amount_out: Minimum tokens expected (raw token units)
minimum_amount_out: Exact tokens to buy (raw token units)
address_provider: Platform address provider
Returns:
+9 -7
View File
@@ -285,24 +285,26 @@ class PlatformAwareBuyer(Trader):
token_amount *= 1 - pool_state.get("fee_fraction", 0.0)
token_amount *= 1 - pool_state.get("transfer_fee_bps", 0) / 10_000
minimum_token_amount = token_amount * (1 - self.slippage)
minimum_token_amount_raw = int(minimum_token_amount * 10**TOKEN_DECIMALS)
# An exact-in program spends all of amount_in, so it gets the
# configured amount; otherwise amount_in is a ceiling padded for
# slippage. In the quote mint's raw units either way.
# Slippage goes on whichever side the program leaves open. An
# exact-in program spends all of amount_in, so it gets the
# configured amount and a token floor. Otherwise the token count
# is exact (pump.fun buy_v2) and amount_in is the ceiling.
if getattr(instruction_builder, "spends_exact_amount_in", False):
max_quote_amount_raw = int(quote_amount * quote_unit)
token_amount_raw = int(
token_amount * (1 - self.slippage) * 10**TOKEN_DECIMALS
)
else:
max_quote_amount_raw = int(
quote_amount * quote_unit * (1 + self.slippage)
)
token_amount_raw = int(token_amount * 10**TOKEN_DECIMALS)
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)
token_amount_raw, # minimum_amount_out (tokens)
address_provider,
)
@@ -0,0 +1,162 @@
"""Verify a pump.fun buy asks for the whole token amount, not the slippage floor.
`buy_v2` takes an exact token count and a maximum quote cost. The trader passed
it `token_amount * (1 - slippage)` as the count — a floor meant for exact-in
programs — so every pump.fun buy received that much fewer tokens than sized:
half at slippage 0.5. Slippage belongs on the cost ceiling, which is where the
cookbook buys put it.
Offline machine checks, no network and no funds moved. The buy runs through
the real pump.fun instruction builder; the `buy_v2` instruction data is decoded:
A. extreme_fast_mode: `amount` is exactly `extreme_fast_token_amount` and
`max_sol_cost` is the buy amount plus slippage.
B. Regular path: `amount` is the buy amount over the curve price, unreduced,
and `max_sol_cost` is the buy amount plus slippage.
Usage:
uv run tests/regression/verify_pumpfun_buy_token_amount.py
"""
import asyncio
import struct
import sys
from pathlib import Path
from types import SimpleNamespace
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from solders.pubkey import Pubkey # noqa: E402
from core.pubkeys import WSOL_MINT, SystemAddresses # noqa: E402
from interfaces.core import Platform, TokenInfo # noqa: E402
from platforms.pumpfun.address_provider import ( # noqa: E402
PumpFunAddresses,
PumpFunAddressProvider,
)
from platforms.pumpfun.instruction_builder import PumpFunInstructionBuilder # noqa: E402
from trading import platform_aware # noqa: E402
from trading.platform_aware import PlatformAwareBuyer # noqa: E402
from utils.idl_manager import get_idl_manager # noqa: E402
TRADER = Pubkey.from_string("11111111111111111111111111111112")
MINT = Pubkey.new_unique()
PROVIDER = PumpFunAddressProvider()
BUILDER = PumpFunInstructionBuilder(get_idl_manager().get_parser(Platform.PUMP_FUN))
BUY_AMOUNT = 0.002
SLIPPAGE = 0.5
FAST_TOKENS = 50_000
PRICE = 0.00000004
def _token_info() -> TokenInfo:
bonding_curve = PROVIDER.derive_pool_address(MINT)
return TokenInfo(
name="T",
symbol="T",
uri="",
mint=MINT,
platform=Platform.PUMP_FUN,
bonding_curve=bonding_curve,
associated_bonding_curve=PROVIDER.derive_associated_bonding_curve(
MINT, bonding_curve, SystemAddresses.TOKEN_2022_PROGRAM
),
user=TRADER,
creator=TRADER,
creator_vault=PROVIDER.derive_creator_vault(TRADER),
token_program_id=SystemAddresses.TOKEN_2022_PROGRAM,
quote_mint=WSOL_MINT,
state_from_event=True,
)
class _Client:
def __init__(self) -> None:
self.sent: list = []
async def build_and_send_transaction(self, instructions, *_a, **_k) -> str:
self.sent.append(instructions)
return "STUB_SIGNATURE"
async def confirm_transaction(self, *_a, **_k) -> bool:
return False
class _Curve:
async def get_pool_state(self, _pool, commitment=None) -> dict: # noqa: ARG002
return {
"price_per_token": PRICE,
"quote_mint": WSOL_MINT,
"creator": str(TRADER),
"is_mayhem_mode": False,
"is_cashback_coin": False,
}
async def _no_fee(_accounts: list) -> None:
return None
def _buy_v2_args(*, extreme_fast: bool) -> tuple[int, int]:
"""Run one buy and return buy_v2's (amount, max_sol_cost)."""
platform_aware.get_platform_implementations = lambda _p, _c: SimpleNamespace(
address_provider=PROVIDER, instruction_builder=BUILDER, curve_manager=_Curve()
)
client = _Client()
buyer = PlatformAwareBuyer(
client,
SimpleNamespace(pubkey=TRADER, keypair=None),
SimpleNamespace(calculate_priority_fee=_no_fee),
amount=BUY_AMOUNT,
slippage=SLIPPAGE,
max_retries=1,
extreme_fast_token_amount=FAST_TOKENS,
extreme_fast_mode=extreme_fast,
)
asyncio.run(buyer.execute(_token_info()))
buy = next(ix for ix in client.sent[0] if ix.program_id == PumpFunAddresses.PROGRAM)
return struct.unpack_from("<QQ", bytes(buy.data), 8)
def check_a_extreme_fast() -> bool:
"""A: the fixed token count, whole; slippage on the cost."""
amount, max_cost = _buy_v2_args(extreme_fast=True)
ok = amount == FAST_TOKENS * 10**6 and max_cost == int(
BUY_AMOUNT * 1e9 * (1 + SLIPPAGE)
)
if not ok:
print(f" amount={amount} max_sol_cost={max_cost}")
return ok
def check_b_regular_path() -> bool:
"""B: buy amount over price, unreduced; slippage on the cost."""
amount, max_cost = _buy_v2_args(extreme_fast=False)
expected = int(BUY_AMOUNT / PRICE * 10**6)
ok = abs(amount - expected) <= 1 and max_cost == int(
BUY_AMOUNT * 1e9 * (1 + SLIPPAGE)
)
if not ok:
print(f" amount={amount} expected {expected} max_sol_cost={max_cost}")
return ok
def main() -> int:
"""Run every check and report."""
checks = [
("A: extreme_fast_mode buys the whole token count", check_a_extreme_fast),
("B: regular path buys the whole token count", check_b_regular_path),
]
passed = 0
for label, check in checks:
ok = check()
print(f"{'PASS' if ok else 'FAIL'} {label}")
passed += ok
print(f"\n{passed}/{len(checks)} checks passed")
return 0 if passed == len(checks) else 1
if __name__ == "__main__":
sys.exit(main())