Files
pumpfun-bonkfun-bot_github/learning-examples/manual_buy.py
T
13b9ec2baf fix(monitoring,core): keep detecting tokens after the Solana transaction v1 cutover (#212)
* fix(monitoring,core): keep detecting tokens after the Solana transaction v1 cutover

Solana transaction v1 (SIMD-0296/0385) went live at epoch 1035 on 2026-09-15.
`maxSupportedTransactionVersion` turns out to be a whole-frame setting, not a
per-transaction filter: asking blockSubscribe for 0 does not skip the v1
transactions in a block, it nulls out `value.block` for the entire
notification, which reads as a skipped slot. Measured against mainnet
2026-09-16, 60s per run on the pump program: 0 delivered 1 block and 177
nulls, 1 delivered 78 blocks and no nulls.

solders 0.26 cannot deserialize a v1 envelope either, but it does not need to.
The RPC has already decoded it by the time `meta.logMessages` is emitted, and
the platform parsers prefer the CreateEvent in those logs anyway, for the
canonical creator. What broke was the dispatch: _process_block_transactions
decoded the bytes first only to learn which program a transaction touched, and
swallowed the failure in a bare `except Exception: pass`. It now tries
_parse_from_logs on every transaction first and keeps the byte decode as a
fallback, so v1 coins arrive with state_from_event=True and extreme_fast_mode
keeps its zero-RPC contract.

Also raises every other reader to v1, fixes the null-block crash in
listen_blocksubscribe.py, and records that PumpPortal does not push coins
created in a v1 transaction — 0 of 6 measured, against near-complete v0
coverage. That one is upstream and not fixable here.

Closes #197

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LDiKnRFW49m3T1rKYiz98

* fix(examples): survive a v1 envelope and a null block per notification

Raising maxSupportedTransactionVersion to 1 means these examples now receive
the v1 transactions the RPC used to withhold, and solders 0.26 cannot decode
one. manual_buy.py had no handler at all around from_bytes, so the first v1
transaction in a block would have ended listen_for_create_transaction outright
— a regression introduced by the version bump itself.

Guards the null `value.block` in the same three places while here: the key is
present and the value is not for a skipped slot, so the membership test raises
TypeError and costs the whole notification. Same root cause as #196.

Found by CodeRabbit on #212.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LDiKnRFW49m3T1rKYiz98

* fix(examples): raise the shared confirm helper to v1 and decode create_v2's OptionU64

Two gaps the first pass missed, both found by running the changed code against
mainnet instead of only exercising it offline.

tx_status.py confirmed through solana-py's snake_case
`max_supported_transaction_version=0`, a spelling the source scan in
verify_transaction_v1.py did not know about — so the sweep reported clean while
the helper every example confirms through still rejected v1. Verified live: at 0
it raises UnsupportedTransactionVersionMessage on a v1 signature, at 1 it reads
meta.err. The scan now covers both spellings, and setting the threshold above 1
makes it name all seven call sites, so the check is not vacuous.

listen_blocksubscribe.py then turned out to have a third cause behind #197 that
no offline check could reach, because until this branch the example never
received a block to decode: its local create_v2 decoder raises on OptionU64,
the arg added by the 2026-09-15 program upgrade. Live before the fix, 90s: 0
creates decoded, 8 tracebacks. After: 58 creates, 0 tracebacks. Trailing option
args are reported unset rather than as a fabricated default, matching
utils/idl_parser.py and the three wire forms that are live on chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LDiKnRFW49m3T1rKYiz98

* fix(examples): decode create_v2's trailing optional args in manual_buy

Found by running the script against mainnet with real funds. It crashed before
it could buy:

    File "learning-examples/manual_buy.py", line 316, in decode_create_instruction
        value = bool(ix_data[offset])
    IndexError: index out of range

decode_create_instruction treated every `defined` arg as a 1-byte OptionBool and
read it unconditionally. create_v2's trailing args are positional and legally
absent from the wire, and the 2026-09-15 upgrade added creator_fee_bps as an
8-byte OptionU64, so the fixed read walks off the end on the shorter forms —
which is most coins. Same defect class as the OptionU64 crash in
listen_blocksubscribe.py, and invisible until this branch let either script
receive a block at all.

Absent trailing options are now reported as None, meaning unset, matching
utils/idl_parser.py. Verified live: the script decodes a create with
creator_fee_bps and is_holder_reward both null, then buys 69,387 tokens for
0.003 SOL, confirmed on-chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LDiKnRFW49m3T1rKYiz98

* refactor(examples): drop one v1 fixture and trim the other

Two fixtures was one too many. The v0 one was captured to prove the byte-decode
path still works, but it never did: the listener routes on logs first, so the
fixture was caught there and the check passed without exercising what its name
claimed. Feeding it through with the logs stripped shows the byte fallback does
not parse a v0 create_v2 either — the parser's own logs-first branch was always
doing the detection. So the check now reuses the create fixture already
committed for verify_create_v2_optional_args.

The v1 fixture stays, because a v1 transaction cannot be synthesized locally —
solders 0.26 cannot build one — and every verifier here runs offline. Trimmed
from 30KB to 13KB by dropping what nothing reads: innerInstructions, token
balances and pre/post balances. What is left is the envelope, the version and
the logs the listener parses.

Adds the check that was missing: strip the logs and the v1 create must vanish.
That is what makes the log route load-bearing rather than decorative, and it
will start failing the day solders learns to decode a v1 envelope, which is
exactly when the routing could be simplified again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013LDiKnRFW49m3T1rKYiz98

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 08:27:30 +02:00

521 lines
21 KiB
Python

"""Buy the next pump.fun coin to be created, using buy_v2.
WARNING: this submits a real transaction and spends real funds.
Usage:
uv run learning-examples/manual_buy.py
uv run learning-examples/manual_buy.py --cu-optimized
`--cu-optimized` adds a SetLoadedAccountsDataSizeLimit instruction. A transaction
may load up to 64 MB of account data by default, which is billed at 16k CU toward
the fee and priority calculation. Declaring a smaller ceiling lowers that share.
The saving does not show up in a transaction's reported `unitsConsumed`, which
only covers execution, so it is hard to measure directly from a receipt.
Do not lower the limit too far: 16 MB is still 4x smaller than the default and
leaves room for Token-2022 mints with extensions, while 512 KB is rejected with
MaxLoadedAccountsDataSizeExceeded on exactly those coins.
Reference: https://www.anza.xyz/blog/cu-optimization-with-setloadedaccountsdatasizelimit
"""
import asyncio
import base64
import hashlib
import json
import os
import struct
import sys
import base58
import pump_v2
import tx_status
import websockets
from dotenv import load_dotenv
from solana.rpc.async_api import AsyncClient
from solana.rpc.commitment import Confirmed
from solana.rpc.types import TxOpts
from solders.account import Account
from solders.compute_budget import set_compute_unit_price
from solders.instruction import Instruction
from solders.keypair import Keypair
from solders.message import Message
from solders.pubkey import Pubkey
from solders.transaction import Transaction, VersionedTransaction
from spl.token.instructions import (
create_idempotent_associated_token_account,
)
# Here and later all the discriminators are precalculated. See learning-examples/calculate_discriminator.py
EXPECTED_DISCRIMINATOR = pump_v2.BONDING_CURVE_DISCRIMINATOR
TOKEN_DECIMALS = 6
COMPUTE_BUDGET_PROGRAM = Pubkey.from_string(
"ComputeBudget111111111111111111111111111111"
)
# 16 MB. Enough for Token-2022 mints carrying extensions, and still 4x below the
# 64 MB default; 4-8 MB is rejected with MaxLoadedAccountsDataSizeExceeded.
LOADED_ACCOUNTS_DATA_SIZE_LIMIT = 16_384_000
# Global constants
PUMP_PROGRAM = Pubkey.from_string("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P")
PUMP_GLOBAL = Pubkey.from_string("4wTV1YmiEkRvAtNtsSGPtUrqRYQMe5SKy2uB4Jjaxnjf")
PUMP_EVENT_AUTHORITY = Pubkey.from_string(
"Ce6TQqeHC9p8KetsN6JsjHK7UTZk7nasjjnr7XxXp9F1"
)
PUMP_FEE = Pubkey.from_string("CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM")
PUMP_FEE_PROGRAM = Pubkey.from_string("pfeeUxB6jkeY1Hxd7CsFCAjcbHA9rWtchMGdZ6VojVZ")
SYSTEM_PROGRAM = Pubkey.from_string("11111111111111111111111111111111")
SYSTEM_TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
TOKEN_2022_PROGRAM = Pubkey.from_string("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb")
SYSTEM_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM = Pubkey.from_string(
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
)
SOL = Pubkey.from_string("So11111111111111111111111111111111111111112")
LAMPORTS_PER_SOL = 1_000_000_000
# RPC ENDPOINTS
load_dotenv()
RPC_ENDPOINT = os.environ.get("SOLANA_NODE_RPC_ENDPOINT")
RPC_WEBSOCKET = os.environ.get("SOLANA_NODE_WSS_ENDPOINT")
# logsSubscribe frames exceed the websockets library's 1 MiB default, which
# closes the connection with 1009 ("message too big").
WEBSOCKET_MAX_MESSAGE_BYTES = 32 * 1024 * 1024
# The bonding curve account and the v2 instruction layout live in pump_v2 so
# every example shares one copy. See learning-examples/pump_v2.py.
BondingCurveState = pump_v2.BondingCurveState
async def get_pump_curve_state(
conn: AsyncClient, curve_address: Pubkey
) -> pump_v2.BondingCurveState:
"""Fetch and parse a bonding curve account.
Args:
conn: Solana RPC client
curve_address: Bonding curve address
Returns:
Parsed curve state
Raises:
ValueError: If the account is missing or not a bonding curve
"""
response = await conn.get_account_info(curve_address, encoding="base64")
if not response.value or not response.value.data:
raise ValueError("Invalid curve state: No data")
return pump_v2.BondingCurveState(response.value.data)
async def _get_account_info(conn: AsyncClient, address: Pubkey) -> Account:
"""Fetch an account, unwrapping AsyncClient's `.value` envelope.
Adapter for `pump_v2.resolve_quote_token_program`, which expects a
getter returning the account object (with an `.owner` attribute)
directly rather than solana-py's RPC response wrapper.
Args:
conn: Solana RPC client
address: Account to fetch
Returns:
The account object
Raises:
ValueError: If the account does not exist
"""
response = await conn.get_account_info(address)
if response.value is None:
raise ValueError(f"Could not fetch account info for {address}")
return response.value
def calculate_pump_curve_price(curve_state: pump_v2.BondingCurveState) -> float:
"""Price of one whole token in whole quote units.
Args:
curve_state: Parsed curve state
Returns:
Price in the curve's quote asset
Raises:
ValueError: If reserves are empty
"""
price = curve_state.price_per_token()
if price <= 0:
raise ValueError("Invalid reserve state")
return price
def set_loaded_accounts_data_size_limit(bytes_limit: int) -> Instruction:
"""Build a SetLoadedAccountsDataSizeLimit compute-budget instruction.
solders does not ship a helper for this one, so encode it by hand: the
compute-budget program takes a 1-byte discriminator (4) and a u32 limit.
Args:
bytes_limit: Max account data the transaction may load, in bytes
Returns:
The compute-budget instruction
"""
data = struct.pack("<BI", 4, bytes_limit)
return Instruction(COMPUTE_BUDGET_PROGRAM, data, [])
async def buy_token(
mint: Pubkey,
bonding_curve: Pubkey,
associated_bonding_curve: Pubkey,
creator_vault: Pubkey,
token_program: Pubkey,
amount: float,
slippage: float = 0.25,
max_retries=5,
*,
cu_optimized: bool = False,
):
private_key = base58.b58decode(os.environ.get("SOLANA_PRIVATE_KEY"))
payer = Keypair.from_bytes(private_key)
async with AsyncClient(RPC_ENDPOINT) as client:
# Fetch bonding curve state for price, mayhem mode and quote asset.
curve_state = await get_pump_curve_state(client, bonding_curve)
token_price_sol = calculate_pump_curve_price(curve_state)
# Amounts are denominated in the curve's quote asset, which is not
# necessarily SOL any more.
quote_mint = pump_v2.normalize_quote_mint(
getattr(curve_state, "quote_mint", None)
)
quote_unit = pump_v2.quote_units(quote_mint)
token_amount = amount / token_price_sol
max_quote_cost = int(amount * quote_unit * (1 + slippage))
# The quote mint can be Token-2022-owned (verified 2026-09-15 upgrade
# note: error 6064 now accepts "SPL Token or Token-2022"), so its
# token program must be resolved rather than assumed. Free for WSOL
# and USDC (pre-seeded in pump_v2's cache); one RPC call otherwise.
quote_token_program_id = await pump_v2.resolve_quote_token_program(
quote_mint, lambda pk: _get_account_info(client, pk)
)
print(f"Quote asset: {quote_mint}")
print(f"Buying {token_amount:.6f} tokens, max cost {max_quote_cost} raw units")
# buy_v2 takes 27 mandatory accounts in a fixed order for every coin.
buy_ix = pump_v2.build_buy_v2_instruction(
base_mint=mint,
creator=curve_state.creator,
user=payer.pubkey(),
token_amount_raw=int(token_amount * 10**TOKEN_DECIMALS),
max_quote_cost_raw=max_quote_cost,
quote_mint=quote_mint,
base_token_program=token_program,
is_mayhem_mode=curve_state.is_mayhem_mode,
quote_token_program_id=quote_token_program_id,
)
instructions = []
if cu_optimized:
# Must come first, before the instructions it applies to.
instructions.append(
set_loaded_accounts_data_size_limit(LOADED_ACCOUNTS_DATA_SIZE_LIMIT)
)
instructions += [
set_compute_unit_price(1_000),
create_idempotent_associated_token_account(
payer.pubkey(), payer.pubkey(), mint, token_program_id=token_program
),
]
# SOL-paired coins settle in native SOL and only seed-check the quote
# ATA, so creating it would waste rent. Other quotes need a real account.
if not pump_v2.is_sol_paired(quote_mint):
instructions.append(
create_idempotent_associated_token_account(
payer.pubkey(),
payer.pubkey(),
quote_mint,
token_program_id=quote_token_program_id,
)
)
instructions.append(buy_ix)
msg = Message(instructions, payer.pubkey())
recent_blockhash = await client.get_latest_blockhash()
opts = TxOpts(skip_preflight=True, preflight_commitment=Confirmed)
for attempt in range(max_retries):
try:
tx_buy = await client.send_transaction(
Transaction(
[payer],
msg,
recent_blockhash.value.blockhash,
),
opts=opts,
)
tx_hash = tx_buy.value
print(f"Transaction sent: https://explorer.solana.com/tx/{tx_hash}")
await tx_status.confirm_and_assert(client, tx_hash)
print("Transaction confirmed")
return # Success, exit the function
except tx_status.TransactionRevertedError as e:
# The signature is already on chain and reverted. The message and
# blockhash below are fixed, so a retry would resubmit identical
# bytes and revert identically — stop instead of burning attempts.
print(f"Transaction reverted on-chain, not retrying: {e}")
return
except Exception as e:
print(f"Attempt {attempt + 1} failed: {str(e)[:50]}")
if attempt < max_retries - 1:
wait_time = 2**attempt
print(f"Retrying in {wait_time} seconds...")
await asyncio.sleep(wait_time)
else:
print("Max retries reached. Unable to complete the transaction.")
def load_idl(file_path):
with open(file_path) as f:
return json.load(f)
def calculate_discriminator(instruction_name):
sha = hashlib.sha256()
sha.update(instruction_name.encode("utf-8"))
return struct.unpack("<Q", sha.digest()[:8])[0]
def decode_create_instruction(ix_data, ix_def, accounts):
args = {}
offset = 8 # Skip 8-byte discriminator
for arg in ix_def["args"]:
t = arg["type"]
if t == "string":
length = struct.unpack_from("<I", ix_data, offset)[0]
offset += 4
value = ix_data[offset : offset + length].decode("utf-8")
offset += length
elif t == "pubkey":
value = base58.b58encode(ix_data[offset : offset + 32]).decode("utf-8")
offset += 32
elif t == "bool":
value = bool(ix_data[offset])
offset += 1
elif isinstance(t, dict) and "defined" in t:
# OptionBool and OptionU64 are single-field Anchor structs with no
# presence tag: each serializes as its bare inner value, 1 and 8
# bytes. They are positional, and the trailing ones are legally
# absent from the wire — reading them unconditionally raises
# IndexError on the shorter forms, which is every coin whose
# create_v2 stops early. An absent one is reported as None, meaning
# unset, matching utils/idl_parser.py.
defined = t["defined"]
name = defined["name"] if isinstance(defined, dict) else defined
if name == "OptionU64":
if offset + 8 > len(ix_data):
value = None
else:
value = struct.unpack_from("<Q", ix_data, offset)[0]
offset += 8
elif offset >= len(ix_data):
value = None
else:
value = bool(ix_data[offset])
offset += 1
else:
raise ValueError(f"Unsupported type: {t}")
args[arg["name"]] = value
# Add accounts
args["mint"] = str(accounts[0])
args["bondingCurve"] = str(accounts[2])
args["associatedBondingCurve"] = str(accounts[3])
args["user"] = str(accounts[7])
return args
async def listen_for_create_transaction():
idl_path = os.path.join(os.path.dirname(__file__), "..", "idl", "pump_fun_idl.json")
idl = load_idl(idl_path)
create_discriminator = calculate_discriminator("global:create")
create_v2_discriminator = calculate_discriminator("global:create_v2")
async with websockets.connect(
RPC_WEBSOCKET, max_size=WEBSOCKET_MAX_MESSAGE_BYTES
) as websocket:
subscription_message = json.dumps(
{
"jsonrpc": "2.0",
"id": 1,
"method": "blockSubscribe",
"params": [
{"mentionsAccountOrProgram": str(PUMP_PROGRAM)},
{
"commitment": "confirmed",
"encoding": "base64",
"showRewards": False,
"transactionDetails": "full",
"maxSupportedTransactionVersion": 1,
},
],
}
)
await websocket.send(subscription_message)
print(f"Subscribed to blocks mentioning program: {PUMP_PROGRAM}")
while True:
response = await websocket.recv()
data = json.loads(response)
if "method" in data and data["method"] == "blockNotification":
if "params" in data and "result" in data["params"]:
block_data = data["params"]["result"]
if "value" in block_data and "block" in block_data["value"]:
block = block_data["value"]["block"]
# `block` is null for a skipped or unavailable slot:
# the key is present, the value is not.
if block and "transactions" in block:
for tx in block["transactions"]:
if isinstance(tx, dict) and "transaction" in tx:
tx_data_decoded = base64.b64decode(
tx["transaction"][0]
)
try:
transaction = (
VersionedTransaction.from_bytes(
tx_data_decoded
)
)
except ValueError:
# A Solana v1 transaction (first byte
# 129, live since 2026-09-15) that the
# installed solders cannot decode. Skip
# it rather than ending the listener —
# the create we are waiting for may be
# later in the same block.
continue
for ix in transaction.message.instructions:
if str(
transaction.message.account_keys[
ix.program_id_index
]
) == str(PUMP_PROGRAM):
ix_data = bytes(ix.data)
discriminator = struct.unpack(
"<Q", ix_data[:8]
)[0]
# Check which create instruction was used
instruction_name = None
token_program = None
if discriminator == create_discriminator:
instruction_name = "create"
token_program = SYSTEM_TOKEN_PROGRAM
elif (
discriminator == create_v2_discriminator
):
instruction_name = "create_v2"
token_program = TOKEN_2022_PROGRAM
if instruction_name:
create_ix = next(
instr
for instr in idl["instructions"]
if instr["name"] == instruction_name
)
# Skip txs that use Address Lookup Tables — their
# instruction account indices reference ALT-loaded keys
# not present in transaction.message.account_keys.
static_keys = (
transaction.message.account_keys
)
if any(
idx >= len(static_keys)
for idx in ix.accounts
):
continue
account_keys = [
str(static_keys[index])
for index in ix.accounts
]
decoded_args = (
decode_create_instruction(
ix_data, create_ix, account_keys
)
)
# Add token program info to decoded args
decoded_args["token_program"] = str(
token_program
)
decoded_args["is_token_2022"] = (
token_program == TOKEN_2022_PROGRAM
)
return decoded_args
async def main(*, cu_optimized: bool = False):
if cu_optimized:
print("Compute-unit optimization enabled (SetLoadedAccountsDataSizeLimit)")
print("Waiting for a new token creation...")
token_data = await listen_for_create_transaction()
print("New token created:")
print(json.dumps(token_data, indent=2))
sleep_duration_sec = 15
print(f"Waiting for {sleep_duration_sec} seconds for things to stabilize...")
await asyncio.sleep(sleep_duration_sec)
mint = Pubkey.from_string(token_data["mint"])
bonding_curve = Pubkey.from_string(token_data["bondingCurve"])
associated_bonding_curve = Pubkey.from_string(token_data["associatedBondingCurve"])
creator_vault = pump_v2.find_creator_vault(
Pubkey.from_string(token_data["creator"])
)
token_program = Pubkey.from_string(token_data["token_program"])
# Fetch the token price
async with AsyncClient(RPC_ENDPOINT) as client:
curve_state = await get_pump_curve_state(client, bonding_curve)
token_price_sol = calculate_pump_curve_price(curve_state)
# Amount of SOL to spend (adjust as needed)
amount = 0.000_001 # 0.00001 SOL
slippage = 0.3 # 30% slippage tolerance
print(f"Bonding curve address: {bonding_curve}")
print(
f"Token Program: {token_program} ({'Token2022' if token_data['is_token_2022'] else 'Standard Token'})"
)
print(f"Token price: {token_price_sol:.10f} SOL")
print(
f"Buying {amount:.6f} SOL worth of the new token with {slippage * 100:.1f}% slippage tolerance..."
)
await buy_token(
mint,
bonding_curve,
associated_bonding_curve,
creator_vault,
token_program,
amount,
slippage,
cu_optimized=cu_optimized,
)
if __name__ == "__main__":
asyncio.run(main(cu_optimized="--cu-optimized" in sys.argv))