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

Refresh the vendored IDLs from pump-fun/pump-public-docs @ 9c82f61 and move all
pump.fun trading onto the v2 instruction interface. This is required, not
optional: legacy buy/sell cannot trade coins paired against a quote asset other
than SOL, and USDC is already whitelisted in the on-chain Global account.

Protocol changes absorbed:

- buy_v2 (27 accounts) / sell_v2 (26 accounts) replace the legacy instructions.
  Every account is mandatory and the order is identical for all coins, so the
  conditional cashback/mayhem account lists are gone. Legacy remains available
  via PumpFunInstructionBuilder(use_legacy_instructions=True).
- BondingCurve is 151 bytes: virtual_sol_reserves -> virtual_quote_reserves,
  real_sol_reserves -> real_quote_reserves, plus quote_mint at offset 83. Old
  field names are kept as aliases so existing callers keep working.
- v2 instruction data drops the track_volume OptionBool; amounts are in the
  quote mint's raw units rather than always lamports.
- create_v2 carries a non-SOL quote mint as optional remaining accounts 17-19,
  and CreateEvent gained quote_mint, so extreme_fast_mode can resolve the quote
  asset without an extra fetch.

USDC support: new trade.quote_amounts and filters.allowed_quote_mints config,
accepting "sol"/"usdc" aliases or raw mints. Amounts are per-quote-mint because
1 USDC and 1 SOL are not interchangeable. A coin whose quote mint has no
configured amount is skipped rather than traded at the wrong size, so SOL-only
configs are unaffected.

Bug fixes found while verifying:

- The logs and blocks listeners set no websocket max_size, so any frame over
  1 MiB closed the connection with 1009 and the token in it was lost. Raised
  to 32 MiB.
- PumpSwap priced against the raw quote vault balance, ignoring the new
  Pool.virtual_quote_reserves (i128 at offset 245; live pools are 301 bytes).
  Upstream's note that this field is 0 everywhere is out of date: a live pool
  carries 17.58 SOL against a 148 SOL vault, a 10.15% price error.
- The seller read curve state once at confirmed commitment and silently fell
  back to create-time values, risking a stale creator_vault and ConstraintSeeds.
  It now retries at processed, matching the buyer.
- Account cleanup would burn wrapped SOL when force_burn was set, destroying
  value that closing the account returns. WSOL is now closed without burning.
- The mint scripts treated a landed transaction as a successful one, so a
  reverted buy printed as success. They now assert the on-chain result.

Compute unit limits retuned from mainnet measurements: buy 100k -> 180k,
sell 60k -> 120k. Mint-and-buy is no longer atomic, because create_v2 plus
buy_v2 exceeds the 1232-byte transaction limit; both mint scripts send two
transactions.

Adds learning-examples/pump_v2.py as one shared, standalone v2 toolkit for the
example scripts, and three verification scripts: an offline layout check
against the IDL, a no-funds mainnet simulation, and a live listener matrix that
buys, sells and closes the ATA per listener.

Verified on mainnet: all four listeners (geyser, logs, blocks, pumpportal) and
all eight example scripts completed a real buy, sell and ATA close, each
confirmed by reading the transaction result back rather than trusting
confirmation alone. The USDC path is verified structurally only; no USDC-paired
coin could be found on-chain to exercise it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Anton Sauchyk
2026-07-28 17:58:33 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 3b88a06d9d
commit 02343b775b
34 changed files with 10947 additions and 3985 deletions
@@ -92,7 +92,11 @@ BREAKING_FEE_RECIPIENTS = [
POOL_DISCRIMINATOR_SIZE = 8
POOL_BASE_MINT_OFFSET = 43 # Where base_mint field starts in pool account data
POOL_MAYHEM_MODE_OFFSET = 243 # Where is_mayhem_mode flag is stored
POOL_IS_CASHBACK_OFFSET = 244 # Where is_cashback_coin flag is stored
POOL_IS_CASHBACK_OFFSET = 244
# virtual_quote_reserves is an i128 appended after the flags. Pool fields
# end at 261; live accounts are 301 bytes with trailing padding.
POOL_VIRTUAL_QUOTE_RESERVES_OFFSET = 245
POOL_VIRTUAL_QUOTE_RESERVES_SIZE = 16
POOL_MAYHEM_MODE_MIN_SIZE = 244 # Minimum size for pool data with mayhem flag
# GlobalConfig structure offsets
@@ -176,6 +180,11 @@ async def get_market_data(client: AsyncClient, market_address: Pubkey) -> dict:
("pool_quote_token_account", "pubkey"),
("lp_supply", "u64"),
("coin_creator", "pubkey"),
# Appended after coin_creator: is_mayhem_mode (243), is_cashback_coin
# (244), then virtual_quote_reserves as an i128 at 245..261. Live pool
# accounts are 301 bytes (fields end at 261, rest is padding).
("is_mayhem_mode", "u8"),
("is_cashback_coin", "u8"),
]
for field_name, field_type in fields:
@@ -328,23 +337,54 @@ async def get_pumpswap_fee_recipients(
# ============================================================================
async def read_virtual_quote_reserves(client: AsyncClient, pool: Pubkey) -> int:
"""Read Pool::virtual_quote_reserves, the field appended after the flags.
Args:
client: Solana RPC client
pool: Pool (market) address
Returns:
Raw virtual quote reserves, or 0 if the account predates the field
"""
response = await client.get_account_info(pool, encoding="base64")
if not response.value or not response.value.data:
return 0
data = response.value.data
end = POOL_VIRTUAL_QUOTE_RESERVES_OFFSET + POOL_VIRTUAL_QUOTE_RESERVES_SIZE
if len(data) < end:
return 0
return int.from_bytes(
data[POOL_VIRTUAL_QUOTE_RESERVES_OFFSET : end], "little", signed=True
)
async def calculate_token_pool_price(
client: AsyncClient,
pool_base_token_account: Pubkey,
pool_quote_token_account: Pubkey,
virtual_quote_reserves: int = 0,
) -> float:
"""Calculate current token price from AMM pool balances.
"""Calculate current token price from AMM pool reserves.
AMM price is determined by the ratio of tokens in the pool:
price = quote_balance / base_balance
Price is the ratio of *effective* quote reserves to base reserves:
effective_quote_reserves =
pool_quote_token_account.amount + Pool::virtual_quote_reserves
PumpSwap added `virtual_quote_reserves` to the Pool account. Upstream's
release note says it is 0 on every pool, but that is out of date: live pools
carry non-zero values (17.58 SOL observed on a 148 SOL pool, i.e. quoting
off the raw vault balance under-prices by ~10.6%). Always add it.
Args:
client: Solana RPC client
pool_base_token_account: Pool's token account (the token being priced)
pool_quote_token_account: Pool's SOL account (the quote currency)
pool_quote_token_account: Pool's quote account
virtual_quote_reserves: Pool::virtual_quote_reserves, in raw quote units
Returns:
Price in SOL per token
Price in quote asset per token
"""
base_balance_resp = await client.get_token_account_balance(pool_base_token_account)
quote_balance_resp = await client.get_token_account_balance(
@@ -352,7 +392,9 @@ async def calculate_token_pool_price(
)
base_amount = float(base_balance_resp.value.ui_amount)
quote_amount = float(quote_balance_resp.value.ui_amount)
quote_decimals = int(quote_balance_resp.value.decimals)
quote_raw = int(quote_balance_resp.value.amount) + int(virtual_quote_reserves)
quote_amount = quote_raw / 10**quote_decimals
return quote_amount / base_amount
@@ -469,7 +511,10 @@ async def sell_pump_swap(
return None
token_price_sol = await calculate_token_pool_price(
client, pool_base_token_account, pool_quote_token_account
client,
pool_base_token_account,
pool_quote_token_account,
await read_virtual_quote_reserves(client, market),
)
print(f"Price per Token: {token_price_sol:.20f} SOL")