Files
pumpfun-bonkfun-bot_github/CLAUDE.md
T
Anton Sauchyk 678fa19fe4 fix(learning-examples): replace getProgramAccounts scan in get_graduating_tokens (#182)
* fix(learning-examples): replace getProgramAccounts scan in get_graduating_tokens

The pump.fun program owns over 10M accounts and no provider will scan it, so
get_graduating_tokens.py could not run at all (#178). getProgramAccountsV2 is
not a fix: it is a provider extension rather than core Agave, and its `limit`
is a scan budget, not a result count, so one filtered answer costs ~1000
sequential pages.

Rewrite discovery onto filtered programSubscribe, which applies dataSize and
memcmp server-side and is accepted even by public api.mainnet-beta.solana.com.
Every write to a curve pushes the full 151-byte account, so progress is
computed per update with no accumulated state. Add a Geyser sibling that
reports the same thing with the slot and signature behind each update.

Also fix two bugs that would have survived the rewrite: the mint lookup
queried SPL Token, which returns nothing for the Token-2022 ATAs that every
create_v2 coin uses, and the threshold was a hardcoded constant rather than
Global.initial_real_token_reserves.

Closes #178

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(learning-examples): drop the graduation cutoff that filtered nothing

zero_prefix_gate offered a cutoff so high that no coin could fail it, so for any
--min-progress below 64.5% the subscription was unfiltered while the banner
reported a filter as active. Offer only the three cutoffs that actually narrow,
and say plainly when none applies.

Rewrite the threshold notes in both scripts in plain English: which cutoffs
exist, whether a given threshold gets one, and the part that matters — the
pre-filter saves bandwidth but does not decide the answer, so the requested
percentage is honoured either way. The banner now names the cutoff as a
percentage instead of byte offsets.

Both directions were checked against mainnet by running the filtered and
unfiltered subscriptions side by side for a minute, on both transports: the
filtered stream matched the below-cutoff set exactly, with 143 of 168 curves
above the cutoff on WebSocket and 128 of 154 on Geyser.

Also document the two scripts in the README example table, and record under
throughput that getProgramAccounts over the whole pump.fun program is no longer
served by any provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 16:52:04 +02:00

18 KiB

Agent guide

Solana trading bot for pump.fun and letsbonk.fun. Snipes newly created tokens and exits on a configured strategy. See README.md for setup and configuration; this file covers what an agent needs that the code doesn't make obvious.

AGENTS.md is a symlink to this file, so Claude Code, Codex, Cursor, and Windsurf all read the same guide.

Ground rules

  • Never run a bot with real funds to test a change. Use learning-examples/, or the simulation scripts below, which move no funds.
  • Never touch .env or print its contents. SOLANA_PRIVATE_KEY is a live key.
  • Don't commit anything from logs/.
  • Test with a learning example before touching src/.

Layout

src/            bot source — this dir is the import root (see below)
learning-examples/   standalone scripts; each runs on its own, no bot config
bots/           one YAML per bot instance
idl/            vendored Anchor IDLs
logs/           {bot_name}_{timestamp}.log

Imports are rooted at src/, not at the repo. uv pip install -e . puts src/ itself on sys.path, so it is from core.client import SolanaClient and from utils.logger import get_loggernot from src.core.... Learning examples are deliberately self-contained: they import siblings like pump_v2 and tx_status as top-level modules and mostly don't import from src at all. Don't "fix" an example by rewiring it to import the bot.

Dependency layers, low to high — don't introduce an upward import:

interfacesutilscoreplatformsmonitoringtradingbot_runner

interfaces is the leaf — it imports nothing internal, and utils/idl_manager.py imports interfaces.core. geyser holds only generated stubs and likewise imports nothing internal; cleanup sits on core/utils and is pulled in by trading.

Platform differences are resolved through interfaces/core.py abstractions (AddressProvider, curve manager, event parser, instruction builder) and a registry in platforms/__init__.py. Listeners and the trader are platform-agnostic (Universal*); anything platform-shaped belongs under platforms/<name>/.

Naming inside learning-examples/

  • Directories are kebab-case (bonding-curve-progress, listen-new-tokens, copy-trading). A single-token product name stays one word (pumpswap).
  • Files are snake_case, verb firstfetch_price.py, decode_from_*.py, extract_blocksubscribe_transactions.py, verify_*.py, simulate_*.py. Exceptions are the shared helper modules pump_v2.py and tx_status.py, which are libraries rather than runnable scripts.
  • RPC and service names are lowercased into one token, never camelCase: blocksubscribe, logsubscribe, programsubscribe, getaccountinfo, gettransaction, pumpportal. So decode_from_gettransaction.py, not decode_from_getTransaction.py.
  • Fixtures are raw_<what>_from_<method>.json next to the script that reads them, under the same rules.
  • simulate_* and verify_* never move funds — that half of the naming is load-bearing and machine-checked. The inverse is not true: live_* is not the only prefix that spends. manual_* (including the pumpswap/ and letsbonk-buy-sell/ ones), mint_and_buy* and cleanup_accounts.py all submit real transactions. Read the module docstring before running anything that is not simulate_* or verify_*.

Commands

uv sync                      # install runtime deps + the dev group (ruff)
uv pip install -e .          # editable install (required for the imports above)
pump_bot                     # run all enabled bots
uv run src/bot_runner.py     # same, without the console script

Lint and format the files you touched, not the whole tree:

uv run ruff check --fix <paths> && uv run ruff format <paths>

A bare uv run ruff check reports ~1700 pre-existing errors across the repo. That is the known baseline, not something your change caused — don't try to fix it wholesale, and don't read it as a failing build. Just don't add new ones in the files you edit.

Ruff config lives in pyproject.toml: line length 88, double quotes, target py311, E501 ignored. Selected rule families include ANN (type annotations), S (security), BLE/TRY (exceptions), C90/PL (complexity), ERA (no commented-out code). Type-hint public functions, Google-style docstrings, and get_logger(__name__) for logging.

Python 3.11+ (requires-python = ">=3.11", matching ruff's target). Runtime deps are declared in [project.dependencies]; ruff and grpcio-tools live in [dependency-groups] dev, which uv sync installs by default. grpcio-tools is protoc — needed only to regenerate the geyser_pb2 stubs in src/geyser/generated/ from src/geyser/proto/, never at runtime. That is the only copy: the geyser examples reach it by putting the repo root on sys.path and importing src.geyser.generated. Don't add a second copy under learning-examples/ — the last one drifted out of sync with the protos.

Verifying pump.fun v2 trade instructions

# Offline: cross-check buy_v2/sell_v2 account layouts, PDA/ATA derivations,
# instruction encoding and quote-asset config against idl/pump_fun_idl.json
uv run learning-examples/verify_v2_account_layout.py

# Mainnet, no funds moved: simulate buy_v2/sell_v2 for one coin, report CU
uv run learning-examples/simulate_v2_trades.py <MINT>

# Mainnet, no funds moved: run the bot's whole buy path against a fresh coin
uv run learning-examples/simulate_bot_buy_path.py
uv run learning-examples/simulate_bot_buy_path.py --no-extreme-fast

Run all three after any pump.fun program upgrade. The simulations report unitsConsumed; use it to retune get_buy_compute_unit_limit / get_sell_compute_unit_limit in platforms/pumpfun/instruction_builder.py.

Verifying transaction-status handling

# Offline: stub checks plus a scan that every example verifies meta.err
uv run learning-examples/verify_tx_status_checks.py

# Adds a mainnet replay of the reverted signatures from issue #175
uv run learning-examples/verify_tx_status_checks.py --live

confirm_transaction answers "did this land in a block?", never "did it succeed". A landed transaction can have reverted, and RPC reports that only in meta.err. Reporting success without reading it is issue #175: buys reverting with BuybackFeeRecipientMissing (6062) printed as confirmed buys.

  • Examples use learning-examples/tx_status.pyconfirm_and_assert in place of a bare confirm_transaction, or assert_transaction_succeeded after one. The verifier above fails the build if a new example skips it.
  • The bot uses SolanaClient.confirm_transaction, which folds meta.err into its return value. Read the boolean — discarding it is the same bug.
  • _get_transaction_result must send maxSupportedTransactionVersion: 0 or the RPC rejects every versioned (v0) transaction with -32015, and a good trade reads back as unconfirmed.
  • build_and_send_transaction returns a solders Signature, not a str. A Signature is not JSON serializable and does not support slicing; a str is rejected by solana-py's confirm_transaction. Normalize at the boundary.
  • post_rpc must catch asyncio.TimeoutError alongside aiohttp.ClientError. aiohttp raises the former when the request timeout fires and it is not a ClientError, so leaving it out lets every RPC timeout escape unretried — and str() on it is empty, so the caller logs a blank reason. A slow getAccountInfo is enough to take down a whole listener run this way.

Listener and decoder pitfalls

Each of these was a live bug in learning-examples/, all of them invisible offline and only visible after a couple of minutes against mainnet.

  • A while True: recv() loop must break out on websockets.ConnectionClosed. Catching it in a broad except Exception that only logs makes the next recv() raise immediately, forever: listen-new-tokens/compare_listeners.py produced 13,090,862 error lines / 888 MB in 150 s and never reached its own 30-second report. The outer reconnect handler with its sleep is unreachable in that shape. A narrow except TimeoutError or except json.JSONDecodeError is fine to swallow — those are per-message, not per-connection.
  • Resolve v0 lookup-table accounts before indexing them. An instruction's account indices can point past message.account_keys into the address lookup table, which geyser reports in meta.loaded_writable_addresses then loaded_readonly_addresses (that order). Ignoring them crashed the geyser example with IndexError after ~11 coins in 150 s; resolving them removed the crash and brought its detection count level with the WebSocket listeners (35 coins each over the same window).
  • Identify an instruction by its 8-byte discriminator, never by account count. Several pump.fun instructions share a count, so counting mislabels them and then prints every account under the wrong name — a real 19-account create_v2 was reported as claim_cashback. Note buy_exact_sol_in is also 18 accounts on chain, same as legacy buy.
  • Walk meta.innerInstructions, not just message.instructions. Most trades reach the program as a CPI from a router or aggregator: in 40 consecutive pump.fun transactions there was 1 top-level pump instruction against 8 inner ones. Anchor's event-CPI prefix (e445a52e51cb9a1d) accounts for a good share of the inner instructions; the event's own discriminator follows it.
  • getProgramAccounts over the whole pump program is rejected by current providers: "Too many accounts requested (10000001 pubkeys) … use getProgramAccountsV2 with pagination". It still works against pump-amm, which is small enough. Don't take that error message as a fix: getProgramAccountsV2 is a provider extension (Helius, Solana Tracker), not core Agave, and its limit is a scan budget rather than a result count — a page can legally return zero accounts and a non-null paginationKey, so one filtered answer over the pump program costs ~1000 sequential pages. Reach for a filtered subscription instead; see the two get_graduating_tokens*.py examples.
  • Filtered programSubscribe on the pump program is the portable way to find curves by state. dataSize + memcmp are applied server-side, and it is accepted even by the public api.mainnet-beta.solana.com. memcmp only matches exact bytes, so it cannot express "reserves below X" — only a handful of fixed cutoffs. Treat it as a bandwidth saver and do the real comparison client-side; don't assume a threshold is being enforced upstream. Geyser's account filters have the same shape and add the slot and signature.
  • Resolve a curve's mint under Token-2022, not SPL Token. The curve account has no mint field and ["bonding-curve", mint] is not reversible, so the mint comes from the associated bonding curve ATA — which is Token-2022 for every create_v2 coin. get_token_accounts_by_owner with the SPL Token program returns an empty list for all of them, silently. Verified four for four on live curves, each confirmed by re-deriving the curve PDA from the recovered mint.
  • SetLoadedAccountsDataSizeLimit must stay generous: 16 MB, not 512 KB. Verified by simulation on a Token-2022 mint with extensions — 512 KB and 4 MB both fail MaxLoadedAccountsDataSizeExceeded with unitsConsumed=0 (never executed), while 16 MB reaches the buy instruction and is still 4x under the 64 MB default. solders has no builder for it; encode struct.pack("<BI", 4, n) against the compute-budget program.

Pump.fun protocol notes (gotchas)

The IDLs under idl/ are vendored verbatim from github.com/pump-fun/pump-public-docs (idl/pump.jsonpump_fun_idl.json, pump_amm.jsonpump_swap_idl.json, pump_fees.json). Refresh them from upstream rather than hand-editing.

Quote assets and the v2 trade instructions (current path)

  • pump.fun supports quote assets other than SOL. BondingCurve.quote_mint is Pubkey::default() (all zeros) for SOL-paired coins; USDC (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v) is whitelisted in Global. Legacy buy/sell cannot trade non-SOL-paired coins at all.
  • The bot uses buy_v2 (27 accounts) and sell_v2 (26 accounts). Every account is mandatory and the order is identical for every coin — SOL or USDC paired, mayhem or not, cashback or not. sell_v2 is buy_v2 minus global_volume_accumulator. Layouts live in _BUY_V2_ACCOUNTS / _SELL_V2_ACCOUNTS in platforms/pumpfun/instruction_builder.py and are machine-checked against the IDL by learning-examples/verify_v2_account_layout.py.
  • v2 args carry no track_volume OptionBool (24-byte data: discriminator + two u64). Volume tracking is unconditional now that user_volume_accumulator is mandatory. max_sol_cost/min_sol_output are in the quote mint's raw units — lamports for SOL, 1e-6 for USDC.
  • Even for SOL-paired coins you must pass wrapped SOL as quote_mint, not Pubkey::default(). Transfers still happen in native SOL, and the associated_quote_* accounts are only seed-constrained — do not create the user's WSOL ATA, it would burn ~0.002 SOL of rent for nothing.
  • Fee recipients: 24 total, in three sets of 8 (NORMAL_FEE_RECIPIENTS, RESERVED_FEE_RECIPIENTS for mayhem coins, BUYBACK_FEE_RECIPIENTS). Every v2 buy/sell needs a fee_recipient and a buyback_fee_recipient. The set is randomized per tx, per pump.fun's guidance on spreading program throughput.
  • sharing_config (PDA ["sharing-config", base_mint]) lives under the pump fees program, not the pump program. Easy to derive against the wrong program.

BondingCurve account layout

  • The account is 151 bytes: 8-byte discriminator, then virtual_token_reserves, virtual_quote_reserves, real_token_reserves, real_quote_reserves, token_total_supply (u64 each), complete (1B, offset 48), creator (32B, offset 49), is_mayhem_mode (offset 81), is_cashback_coin (offset 82), quote_mint (32B, offset 83), then 36 reserved zero bytes. The documented struct is 115 bytes; the extra 36 are padding.
  • The SOL-named fields were renamed: virtual_sol_reservesvirtual_quote_reserves, real_sol_reservesreal_quote_reserves. The curve manager still exposes the old names as aliases, so pre-existing callers keep working for SOL-paired coins — but anything doing arithmetic must scale by the quote mint's decimals (quote_units_per_token), not a hardcoded 1e9.
  • PumpSwap Pool gained a trailing virtual_quote_reserves: i128 (16 bytes, offset 245). Pool fields end at 261; live accounts are 301 bytes with trailing padding. Quote against effective reserves: pool_quote_token_account.amount + virtual_quote_reserves. Upstream's release note claims it is 0 on all pools — that is out of date. Verified on mainnet: pool 6Bv1JM1deBPe… carries 17.584505433 SOL of virtual reserves against a 148.455 SOL vault, so quoting off the raw vault balance under-prices by ~10.6%. It is i128, not u64 — reading 8 bytes happens to work only while the high half is zero.
  • pump-amm has no buy_v2/sell_v2. The AMM instruction names are unchanged; only the pool layout and quoting moved.

Coin creation

  • The IDL instruction is create_v2 (snake_case). Args: name (str), symbol (str), uri (str), creator (pubkey), is_mayhem_mode (bool), is_cashback_enabled (OptionBool 1B). OptionBool is a struct wrapping a single bool — serialized as 1 byte, not 2.
  • is_cashback_enabled can be absent from the wire entirely. Two mainnet create_v2 instructions carry 0001 and 00 after creator: one sends both trailing args, the other omits the last. A decoder that reads a fixed number of trailing bytes raises IndexError on roughly half of all coins. Decode trailing args defensively and report a missing one as unset.
  • create_v2 accounts 1-16 are in the IDL; accounts 17-19 are optional remaining accounts (quote_mint, associated_quote_bonding_curve, quote_token_program). All three or none. This is the only way to read a new coin's quote asset from the instruction rather than the event. In practice they are appended for SOL-paired coins too, carrying wrapped SOL — a live SOL-paired create_v2 was observed with 19 accounts and account 17 = WSOL — so do not treat a 19-account create_v2 as proof of a non-SOL quote asset. Read quote_mint off the curve instead.
  • The associated bonding curve is an ordinary ATA, so its address depends on which token program owns the mint: Token2022 for create_v2 coins, SPL Token for legacy create. Deriving with the wrong program returns a valid-looking address that does not exist on chain. Verified: curve 3jJ83ND… derives to Cd4iC3Jn… under Token2022 (matches chain) and AhNzZsBp… under SPL Token.
  • extreme_fast_mode skips the curve-state price fetch but still refreshes mayhem/cashback/creator/quote_mint from chain, because the wrong quote mint means spending the wrong balance entirely. Event parsers also populate quote_mint from CreateEvent (which gained quote_mint and virtual_quote_reserves as trailing fields).

Legacy instructions (fallback only)

Retained behind PumpFunInstructionBuilder(..., use_legacy_instructions=True). The IDL under-reports these: buy is 18 accounts on-chain (IDL lists 16) and sell is 16 non-cashback / 17 cashback (IDL lists 14). The extras are bonding-curve-v2 (PDA ["bonding-curve-v2", mint]) followed by a buyback fee recipient (mutable); the cashback sell path also inserts user_volume_accumulator before bonding-curve-v2. On the PumpSwap side the legacy path needs pool-v2 (PDA ["pool-v2", base_mint] under pump-amm) — without it pump-amm throws AnchorError 6023 (Overflow) after the transfers complete, a misleading code for a missing account. Prefer v2 — it is the interface pump.fun maintains.

Config notes

  • Bot YAML supports ${VAR} interpolation from the file named by env_file. Actual variable names are SOLANA_NODE_RPC_ENDPOINT, SOLANA_NODE_WSS_ENDPOINT, SOLANA_PRIVATE_KEY, GEYSER_*.
  • config_loader.py validates the platform/listener pairing before startup: pump.fun supports logs, blocks, geyser, pumpportal; letsbonk.fun supports blocks, geyser, pumpportalnot logs. Adding a listener means updating PLATFORM_LISTENER_COMPATIBILITY there too.
  • Bots with separate_process: true run in their own process. One log file per bot instance.