Verified every claim in CLAUDE.md against the code after the recent debugging, IDL, bot, and learning-example updates. Four were wrong: - The dependency chain listed `utils` before `interfaces`, but utils/idl_manager.py imports interfaces.core and interfaces imports nothing internal. It also made trading and monitoring peers when trading imports monitoring, and omitted cleanup and geyser entirely. - Ruff baseline said ~2400 errors; `uv run ruff check` reports 1676. - `live_*` was presented as the marker for scripts that spend real funds. Only 2 of the 13 scripts that submit transactions carry that prefix — manual_*, mint_and_buy* and cleanup_accounts.py spend too. - compare_listeners.py was cited without its path, ambiguous against listen-migrations/compare_migration_listeners.py. The protocol section (v2 account counts, BondingCurve offsets, PumpSwap i128, PDA seeds, fee-recipient sets, tx-status rules) checks out against idl/pump_fun_idl.json and src/; both offline verifiers still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
17 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
.envor print its contents.SOLANA_PRIVATE_KEYis 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_logger — not 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:
interfaces → utils → core → platforms → monitoring → trading → bot_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 first —
fetch_price.py,decode_from_*.py,extract_blocksubscribe_transactions.py,verify_*.py,simulate_*.py. Exceptions are the shared helper modulespump_v2.pyandtx_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. Sodecode_from_gettransaction.py, notdecode_from_getTransaction.py. - Fixtures are
raw_<what>_from_<method>.jsonnext to the script that reads them, under the same rules. simulate_*andverify_*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 thepumpswap/andletsbonk-buy-sell/ones),mint_and_buy*andcleanup_accounts.pyall submit real transactions. Read the module docstring before running anything that is notsimulate_*orverify_*.
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.py—confirm_and_assertin place of a bareconfirm_transaction, orassert_transaction_succeededafter one. The verifier above fails the build if a new example skips it. - The bot uses
SolanaClient.confirm_transaction, which foldsmeta.errinto its return value. Read the boolean — discarding it is the same bug. _get_transaction_resultmust sendmaxSupportedTransactionVersion: 0or the RPC rejects every versioned (v0) transaction with-32015, and a good trade reads back as unconfirmed.build_and_send_transactionreturns a soldersSignature, not astr. ASignatureis not JSON serializable and does not support slicing; astris rejected by solana-py'sconfirm_transaction. Normalize at the boundary.post_rpcmust catchasyncio.TimeoutErroralongsideaiohttp.ClientError. aiohttp raises the former when the request timeout fires and it is not aClientError, so leaving it out lets every RPC timeout escape unretried — andstr()on it is empty, so the caller logs a blank reason. A slowgetAccountInfois 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 onwebsockets.ConnectionClosed. Catching it in a broadexcept Exceptionthat only logs makes the nextrecv()raise immediately, forever:listen-new-tokens/compare_listeners.pyproduced 13,090,862 error lines / 888 MB in 150 s and never reached its own 30-second report. The outer reconnect handler with itssleepis unreachable in that shape. A narrowexcept TimeoutErrororexcept json.JSONDecodeErroris 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_keysinto the address lookup table, which geyser reports inmeta.loaded_writable_addressesthenloaded_readonly_addresses(that order). Ignoring them crashed the geyser example withIndexErrorafter ~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_v2was reported asclaim_cashback. Notebuy_exact_sol_inis also 18 accounts on chain, same as legacybuy. - Walk
meta.innerInstructions, not justmessage.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. getProgramAccountsover 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.bonding-curve-progress/get_graduating_tokens.pyis knowingly broken on this and needs the V2 pagination rewrite.SetLoadedAccountsDataSizeLimitmust stay generous: 16 MB, not 512 KB. Verified by simulation on a Token-2022 mint with extensions — 512 KB and 4 MB both failMaxLoadedAccountsDataSizeExceededwithunitsConsumed=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; encodestruct.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.json → pump_fun_idl.json, pump_amm.json → pump_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_mintisPubkey::default()(all zeros) for SOL-paired coins; USDC (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v) is whitelisted inGlobal. Legacybuy/sellcannot trade non-SOL-paired coins at all. - The bot uses
buy_v2(27 accounts) andsell_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_v2isbuy_v2minusglobal_volume_accumulator. Layouts live in_BUY_V2_ACCOUNTS/_SELL_V2_ACCOUNTSinplatforms/pumpfun/instruction_builder.pyand are machine-checked against the IDL bylearning-examples/verify_v2_account_layout.py. - v2 args carry no
track_volumeOptionBool (24-byte data: discriminator + two u64). Volume tracking is unconditional now thatuser_volume_accumulatoris mandatory.max_sol_cost/min_sol_outputare 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, notPubkey::default(). Transfers still happen in native SOL, and theassociated_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_RECIPIENTSfor mayhem coins,BUYBACK_FEE_RECIPIENTS). Every v2 buy/sell needs afee_recipientand abuyback_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_reserves→virtual_quote_reserves,real_sol_reserves→real_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
Poolgained a trailingvirtual_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: pool6Bv1JM1deBPe…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 isi128, notu64— 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).OptionBoolis a struct wrapping a single bool — serialized as 1 byte, not 2. is_cashback_enabledcan be absent from the wire entirely. Two mainnetcreate_v2instructions carry0001and00aftercreator: one sends both trailing args, the other omits the last. A decoder that reads a fixed number of trailing bytes raisesIndexErroron roughly half of all coins. Decode trailing args defensively and report a missing one as unset.create_v2accounts 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-pairedcreate_v2was observed with 19 accounts and account 17 = WSOL — so do not treat a 19-accountcreate_v2as proof of a non-SOL quote asset. Readquote_mintoff 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_v2coins, SPL Token for legacycreate. Deriving with the wrong program returns a valid-looking address that does not exist on chain. Verified: curve3jJ83ND…derives toCd4iC3Jn…under Token2022 (matches chain) andAhNzZsBp…under SPL Token. extreme_fast_modeskips 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 populatequote_mintfromCreateEvent(which gainedquote_mintandvirtual_quote_reservesas 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 byenv_file. Actual variable names areSOLANA_NODE_RPC_ENDPOINT,SOLANA_NODE_WSS_ENDPOINT,SOLANA_PRIVATE_KEY,GEYSER_*. config_loader.pyvalidates the platform/listener pairing before startup: pump.fun supportslogs,blocks,geyser,pumpportal; letsbonk.fun supportsblocks,geyser,pumpportal— notlogs. Adding a listener means updatingPLATFORM_LISTENER_COMPATIBILITYthere too.- Bots with
separate_process: truerun in their own process. One log file per bot instance.