CLAUDE.md's coin-creation note and simulate_bot_buy_path's docstring still described the pre-#183 unconditional curve refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
21 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 the listener-to-buy path (issue #170)
# Offline: bonding curve derived from the mint (payload bondingCurveKey not
# trusted), unreadable curve skips the buy instead of submitting with guessed
# accounts, curve+mint read in one slot-consistent batch
uv run learning-examples/verify_pumpportal_buy_path.py
# Offline: extreme_fast_mode stays at ZERO RPC calls between detection and
# submission for CreateEvent-sourced tokens; pumpportal still refreshes
uv run learning-examples/verify_extreme_fast_zero_rpc.py
Fast listeners (pumpportal especially, but geyser too) can announce a token
seconds before every node behind a load-balanced RPC endpoint can read its
accounts — two back-to-back reads on the same endpoint may be served from
nodes at different slots. trade.curve_refresh_budget (seconds, default 2.0)
bounds the pre-buy curve read in extreme_fast_mode; when it expires the token
is skipped, because a buy built from listener-guessed defaults reverts on-chain
with NotAuthorized (6000), ConstraintSeeds (2006) or, on letsbonk,
AccountNotInitialized (3012). The sell path deliberately keeps the opposite
fallback — proceed with cached values — since skipping a sell strands the
position.
The refresh is skipped entirely — extreme_fast_mode's zero-RPC contract —
when TokenInfo.state_from_event is set, i.e. the listener parsed the
CreateEvent (geyser/logs/blocks), which carries the canonical creator,
mayhem/cashback flags and quote_mint. Instruction args.creator is
user-supplied and post-2026-04-28 may differ from the canonical BC.creator
(PFEE PDA delegation), so instruction-parsed TokenInfo deliberately does
not set the flag; the geyser parser prefers meta.log_messages over
instruction decoding for exactly this reason. trade.trust_create_event: false is the escape hatch back to always-refresh. PumpPortal payloads carry
none of these fields and always refresh. Related pitfall: the strict IDL
instruction decoder rejects create_v2 transactions that omit the trailing
is_cashback_enabled OptionBool (a legal wire form), so the instruction path
alone silently misses those coins — one more reason the log/event path is
preferred everywhere.
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. Don't take that error message as a fix:getProgramAccountsV2is a provider extension (Helius, Solana Tracker), not core Agave, and itslimitis a scan budget rather than a result count — a page can legally return zero accounts and a non-nullpaginationKey, so one filtered answer over the pump program costs ~1000 sequential pages. Reach for a filtered subscription instead; see the twoget_graduating_tokens*.pyexamples.- Filtered
programSubscribeon the pump program is the portable way to find curves by state.dataSize+memcmpare applied server-side, and it is accepted even by the publicapi.mainnet-beta.solana.com.memcmponly 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 everycreate_v2coin.get_token_accounts_by_ownerwith 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. 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. Whether it also reads the curve for mayhem/cashback/creator/quote_mint depends on provenance (see "Verifying the listener-to-buy path" above): CreateEvent-sourced tokens (state_from_event) trade on the event data with zero RPC calls, while pumpportal/incomplete-event tokens refresh from chain — the wrong quote mint means spending the wrong balance entirely. Event parsers 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.