* feat(idl): refresh to upstream 8109141 and decode omitted OptionU64 args Vendors pump.json, pump_amm.json and pump_fees.json from pump-fun/pump-public-docs@8109141 (holder rewards coins, cashback deprecation). create_v2 gains two trailing optionals: creator_fee_bps (OptionU64) and is_holder_reward (OptionBool). _is_optional_type only recognised OptionBool, so a create_v2 that omits creator_fee_bps failed the whole decode and the coin dropped out of the instruction path — the same failure #184 fixed for is_cashback_enabled. Reproduced against two live mainnet create_v2 transactions captured 2026-09-15: one omits creator_fee_bps (157-byte instruction data), one sends it (156 bytes) — both are added as getTransaction fixtures under learning-examples/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(learning-examples): accept 125-byte bonding curves in dataSize filters Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(learning-examples): replace unrun/recycled curve-size claims with dated live measurements The 45s/205-update/24-curve figures and the logsSubscribe/same-tx extend_account claim in the two graduating-token docstrings were carried over from before the 125/151 filter change and never re-verified under it. Replaced with fresh 2026-09-15 measurements: a length-unfiltered programSubscribe run, a direct 125->151 transition observed on one curve, and a live run of the Geyser script's own two-group build_subscribe_request confirming both filter groups actually deliver. Unreproduced claims are now marked UNVERIFIED in-line instead of stated flatly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(learning-examples): stop enumerating bonding-curve dataSize lengths extend_account can grow a curve to any length the program allows, so the CURVE_ACCOUNT_LENS = (125, 151) allowlist in both graduating-token scripts was whack-a-mole -- a live 256-byte curve was already missed. Drop the dataSize/datasize server-side filter entirely (discriminator + complete memcmp already restrict delivery to BondingCurve accounts) and replace the allowlist with a MIN_CURVE_LEN client-side floor. Collapse the Geyser script's two named filter groups back to one now that there's no length to fan out over. Extend verify_curve_account_sizes.py to assert neither script constructs a dataSize filter and that 125/151/256-byte curves all decode. Measured live 2026-09-15, filtered vs unfiltered subscribed simultaneously so both see identical trade activity: ~0.6-0.7% extra traffic on WebSocket, statistically zero on Geyser -- dropping the filter is effectively free. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(learning-examples): remove untraceable bandwidth figure, session wording Review of the dataSize-enumeration fix found a docstring figure (90s window, 2/224/866 updates at 256/125/151 bytes) that no command in the fix report actually produced -- it was a byproduct of the discarded sequential-window run, never written down with its command+output. Drop it and rely only on the properly-documented 120s simultaneous measurement already backing the rest of the paragraph. Also replace two "carried from an earlier session" UNVERIFIED markers with the repo's own convention (UNVERIFIED: <what>, no reference to how or when the file was edited), and give both the update and byte ratio (1.006x / 1.007x) instead of a single derived percentage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(pumpfun): surface is_holder_reward and creator_fee_bps on TokenInfo Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(pumpfun): verify the USDC half of the creator_fee_bps claim The comment on TokenInfo.creator_fee_bps stated flatly that SOL- and USDC-paired coins always carry bps 0, but only the SOL half had been observed live. Found 10 USDC-paired bonding curves (via Dune's indexed CreateEvent table) and read each one directly through PumpFunCurveManager.get_pool_state: all 10 show creator_fee_bps=0, matching every custom-pair coin observed showing nonzero. Dated the verification in the comment instead of leaving it an unmarked claim. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(examples): create holder-reward coins; mark cashback legacy-only create_v2 now rejects is_cashback_enabled=[true] with 6082 CashbackDeprecated as of the 2026-09-15 upgrade. Add ENABLE_HOLDER_REWARD to mint_and_buy_v2.py so the trailing create_v2 args can reach is_holder_reward instead, and relabel the still-live cashback code paths in core.py, instruction_builder.py and curve_manager.py with the deprecation date. No cashback account handling is removed anywhere - existing cashback coins keep trading and accruing exactly as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(pumpfun): resolve quote mints to their real token program A 2026-09-15 pump.fun upgrade lets create_v2 pair a coin with a Token-2022 quote mint (error 6064 now accepts "SPL Token or Token-2022"), and such coins already trade on mainnet. quote_token_program() only knew WSOL/USDC and silently defaulted everything else to SPL Token, deriving associated token accounts that don't exist on chain for any other quote mint. Add an async resolver that reads a mint's owner from chain and caches it for the process, pre-seeded with WSOL/USDC so they stay free. The bot warms this cache once at startup for every configured quote mint; the hot path (event parsing, address resolution) only ever reads the cache, preserving extreme_fast_mode's zero-RPC contract between detection and submission. quote_token_program() itself is untouched so its existing call sites keep their non-optional Pubkey return. Also fixes the standalone pump_v2.py learning-examples helper, which had the same bug in a form that ignored its argument entirely, and gives simulate_bot_buy_path.py a quote_amounts entry so it can exercise a non-SOL-paired coin instead of unconditionally skipping it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(core): name Anchor error codes when reporting a reverted transaction confirm_transaction only answers whether a transaction landed; meta.err carries the revert. Logging it raw as {'InstructionError': [idx, {'Custom': n}]} told an operator nothing, and pump.fun's 2026-09-15 upgrade added 22 new error codes to the pump program and 10 to pump-amm, so an unnamed number is now more likely to be wrong than useful. IDLParser.describe_error_code() looks a Custom(N) code up in an IDL's error table; SolanaClient logs the match alongside the raw meta.err at both revert sites, so a buy reverting with BuybackFeeRecipientMissing (issue #175) now prints "6062 BuybackFeeRecipientMissing" instead of a bare 6062. SolanaClient is shared across pump.fun and letsbonk.fun and has no record of which program a given transaction invoked, so this only checks pump.fun's table -- the program the bot's own buy_v2/sell_v2 call directly. A revert on a different program is described against the wrong table if the code number collides, or left unnamed otherwise; see the docstring on _describe_program_error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): correct get_account_info's return type annotation It was typed dict[str, Any] but solana-py's AsyncClient.get_account_info actually returns a solders Account object. Every real caller already reads attributes (.data, .owner) rather than subscripting, so this was latent rather than a live bug -- but the wrong annotation is what let those callers work by accident, and would mislead anyone who trusted the type and reached for result["owner"] instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): tag the revert description with the table it came from The name appended to a reverted transaction's log line looked like a fact about whichever program actually reverted, but it is only ever looked up in pump.fun's error table. This bot also trades letsbonk.fun, so a letsbonk revert whose numeric code happens to also exist in pump's table would get a confident, wrong name -- worse than the bare number it replaced, since a wrong name stops the reader from checking. Prefixing the description with "pump.fun IDL:" carries the same limitation the docstring already stated out to the log line itself, so it's visible without reading the source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: resync CLAUDE.md and README.md with the 2026-09-15 program upgrade The bonding curve is 125 bytes now (not 151, no more padding), create_v2 takes eight args instead of six, cashback creation is deprecated (6082 CashbackDeprecated) while existing cashback coins keep trading, quote assets are no longer just SOL/USDC (QuoteControl, not Global, is the authoritative mint registry), and holder-reward coins were undocumented entirely. Add verify_curve_account_sizes.py to both docs' verification lists, retire the superseded CU comments in instruction_builder.py (the 180k/120k limits are untouched), and fix two stale cashback comments plus a docstring in client.py that showed a msg suffix code 6062 doesn't have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(trading): refresh quote_token_program_id alongside quote_mint _refresh_quote_mint synced token_info.quote_mint from freshly-read curve state but left quote_token_program_id at its construction-time value. A stale non-None program id wins the `or` in PumpFunAddressProvider.resolve_quote, so a curve refresh that reveals a Token-2022 quote mint still resolves the associated_quote_* ATAs under SPL Token -- an address that does not exist on chain. cached_quote_token_program is a synchronous dict read, so this keeps extreme_fast_mode's zero-RPC-call contract between detection and submission. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(examples): resolve the quote mint's real token program in manual buy/sell manual_buy.py and manual_sell.py derived the quote ATA's owning program through pump_v2.quote_token_program, which defaults any mint outside the WSOL/USDC map to SPL Token. A pump.fun upgrade verified 2026-09-15 lets create_v2 pair a coin with a Token-2022 quote mint (error 6064 now accepts "SPL Token or Token-2022"), so that default derives an ATA that does not exist on a Token-2022-quoted coin -- and these scripts submit real transactions, so the failure burns one. Both now call pump_v2.resolve_quote_token_program (free for WSOL/USDC via its cache, one RPC call otherwise) and thread the resolved program into both the buy_v2/sell_v2 instruction and the quote ATA creation. Verified against a real Token-2022-quoted pump.fun coin found via Dune (pumpdotfun_solana.pump_evt_createevent, mint G5Ve125Poi7KoDRP5uLsgu5NQdm3KoqfyUkq4cqtTo4r, quote_mint Xsc9qvGR1efVDFGLrVsmkzv3qi45LTBjeUKSPmx9qEh, confirmed Token-2022-owned by RPC read): the old default derives associated_quote_bonding_curve FViEZvbcYjoSwGE2b37HiCXmPT3N7kSd1nQavFzcD7Yb, which does not exist on chain; the fixed resolution derives DfxcBKXTLDqobNEmR5JkaBeoKqNbJE1vKbtQVbowXz4x, which exists on chain and is owned by the Token-2022 program. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(core): correct get_multiple_accounts's return type annotation Declared as list[Any] while it actually returns a list of solders Account objects (or None for missing accounts) -- the same stale-annotation pattern already corrected on get_account_info. Checked the one caller, PumpFunCurveManager.get_pool_state_and_token_program, which already unpacks the two entries and reads .data / .owner off them directly rather than subscripting -- no live bug behind the wrong annotation here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(examples): use the public decode API in the optional-args verifier check_omitted_option_u64_decodes and check_option_u64_present_decodes reached into IDLParser._decode_instruction_args behind # noqa: SLF001 instead of the public decode_instruction(data, keys, accounts) already used by every other check in this file. The fixtures already carry valid accounts/keys (previously discarded as data, _, _), so the public method expresses the same check -- confirmed it returns byte-for-byte identical decoded args as the private call did. Confirmed the check stays load-bearing: reverting _is_optional_type to a narrow == "OptionBool" comparison drops the verifier to 2/7 checks passed, exit 1, including the two checks touched here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: correct unverified claims from the final branch review Text-only fixes from the whole-branch review — no code defects found. - CLAUDE.md: create_v2 trailing-arg claim was circular (sourced from our own minter script) and wrong — the three committed fixtures show 0, 1 and 2 trailing args, never 3. Rewritten with the counted byte lengths and dated; the unobserved 3-arg form is now marked UNVERIFIED instead of asserted. - src/interfaces/core.py, CLAUDE.md: corrected an inflated "25 SOL-paired" sample to the actual 21 (25 coins captured live, 4 of them custom-pair). - instruction_builder.py: buy_v2/sell_v2 CU comments cited figures from a task brief that were never re-measured on this branch. Re-measured via simulate_v2_trades.py on 2026-09-15 (106,677 / ~77,196 CU) and updated the comments; the configured 180k/120k limits are unchanged. - simulate_bot_buy_path.py: removed a working-session reference ("this task") from a comment. - get_graduating_tokens.py, get_graduating_tokens_geyser.py's sibling docs (pump_v2.py, fetch_price.py, get_bonding_curve_status.py, verify_pumpportal_buy_path.py): made the curve-length story consistent (125/151/256, don't enumerate lengths) instead of a stale "125 or 151" framing; resolved an UNVERIFIED marker that mint_and_buy_v2.py already answers (extend_account can share a transaction with create_v2); removed a redundant restated clause in the bandwidth-measurement paragraph. Verified: all 7 verify_*/simulate offline+mainnet checks still exit 0, buy_v2/sell_v2 account counts unchanged at 27/26. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(trading): resolve quote-mint decimals instead of defaulting to 9 quote_decimals()/quote_units_per_token() defaulted every quote mint outside the WSOL/USDC map to SOL's 9 decimals, mis-scaling max_sol_cost/min_sol_output by up to 1000x for a custom Token-2022 quote mint (verified: 6 decimals). Extend resolve_quote_token_program's existing mint-account fetch to also parse and cache decimals from the same read (byte offset 44, shared by SPL Token and Token-2022), so the fix costs zero extra RPC calls and the zero-RPC extreme_fast_mode contract holds. Mirrored the same treatment in learning-examples/pump_v2.py's standalone copy. Also decode is_holder_reward in the instruction-only create_v2 parse path (a landed transaction proves it, since the program rejects the flag when globally disabled) while leaving creator_fee_bps at its default, since upstream docs say it's ignored for SOL/USDC-paired coins and nothing reads it today. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(examples): strengthen optional-arg verifier assertions Fix verify_create_v2_optional_args.py to properly assert value equality rather than just presence when checking optional arguments: - check_omitted_option_u64_decodes: verify both creator_fee_bps and is_holder_reward are exactly None (not just present in dict) - check_option_u64_present_decodes: decode the fixture, verify creator_fee_bps shape is {"field_0": <value>}, and assert the actual value (0) rather than just non-null - Fixture decodes to creator_fee_bps={'field_0': 0}, is_holder_reward=None Also fix README.md line 166: "Three examples" → "Four examples" to match the four verification commands listed. Temporary revert of idl_parser.py's _is_optional_type confirms the strengthened assertions are load-bearing: changing from startswith("Option") back to == "OptionBool" causes 4 of 7 checks to fail. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EkvFUgSiZoaF7w4xugrgBc --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
27 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
# Offline: 125/151/256-byte bonding curves all decode, and the graduating-
# token examples don't filter on account length
uv run learning-examples/verify_curve_account_sizes.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 four 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 (fixed in #184): the
IDL instruction decoder used to reject create_v2 transactions that omit the
trailing is_cashback_enabled OptionBool (a legal wire form), silently
dropping those coins from the instruction path. It now reports omitted
trailing option-typed args as unset — verify_create_v2_optional_args.py
machine-checks that, and that mandatory args still fail the decode. The
log/event path stays preferred for the canonical-creator reason above.
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.
Verifying the tp/sl exit path (issue #189)
# Offline: the exit sell prices off the price that triggered it, a reverted
# exit sell is retried, and the retry is bounded
uv run learning-examples/verify_tp_sl_exit_price.py
PlatformAwareSeller.execute does not read a price — the token_price it is
handed is the slippage floor (min_quote_output = amount * price * (1 - slippage)). So the caller owns the floor's correctness. A tp/sl exit fires
precisely because price left entry_price, so pricing the sell off the entry
sets a floor the pool cannot pay on a stop-loss and the sell reverts with 6003
TooLittleSolReceived — during the drop the stop-loss exists to escape. On a
take-profit the same mistake runs the other way and the floor protects nothing.
_monitor_position_until_exit already fetches current_price at the top of
each iteration, so passing it costs no extra RPC call; _handle_time_based_exit
genuinely has nothing fresher and keeps passing the buy price.
The seller's max_retries covers transaction submission only. An on-chain
revert comes back as success=False and is not retried there, so the retry has
to happen in the monitor loop, where the price is re-read first.
trade.max_exit_sell_attempts (default 3, validated to 1..100) bounds it so a
token that keeps reverting cannot pin the bot on one position, and the counter
resets if the price recovers out of the exit band. After the last attempt the
position is left open and unmonitored — logged loudly, since the tokens are
still held. Watch the break: before #189 it sat outside both branches of
if sell_result.success:, so a failed sell abandoned the position after a
single try while leaving is_active=True.
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, and it's no longer just
SOL and USDC.
BondingCurve.quote_mintisPubkey::default()(all zeros) for SOL-paired coins; USDC (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v) is one whitelisted entry inGlobal, but coins paired with Token-2022 quote mints are live on chain too. Legacybuy/sellcannot trade non-SOL-paired coins at all. Global.whitelisted_quote_mintsis not the authoritative quote-mint registry. The 2026-09-15 upgrade added aQuoteControlaccount with its own mint list, which is how a coin can pair with a mintGlobalnever lists. Error6064was relaxed to match — it used to require legacy SPL Token and now accepts SPL Token or Token-2022.- The quote mint's token program is resolved from chain, not assumed.
resolve_quote_token_program(src/core/pubkeys.py) reads a mint's owner once — pre-seeded with WSOL/USDC so those stay free — and caches it for the process;cached_quote_token_programis the hot-path read used by event parsing and address resolution. The bot warms this cache once at startup for every configured quote mint, soextreme_fast_mode's zero-RPC contract between detection and submission still holds. - The bot uses
buy_v2(27 accounts) andsell_v2(26 accounts). Every account is mandatory and the order is identical for every coin — whatever quote mint it's paired with, 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
-
create_v2now allocates a 125-byte account, not 151. The 36 reserved padding bytes are gone, and three fields were appended afterquote_mint:creator_fee_bps(u64),can_edit_creator_fee(bool, reserved — always false) andis_holder_reward(bool). Every field the bot reads sits before where the padding used to start, and none of it moved. -
extend_accountcan grow a curve past 125 bytes, and a length allowlist is whack-a-mole against that — don't filter on account length; decode any length at or above 125 the same way:Length What it is 125 bytes What create_v2allocates now (2026-09-15 upgrade)151 bytes The old allocation size; still reachable via extend_account256 bytes A rarer extend_accounttarget, confirmed live 2026-09-15learning-examples/verify_curve_account_sizes.pychecks that all three decode correctly and that the graduating-token examples don't filter on account length. -
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) and takes eight args:name (str), symbol (str), uri (str), creator (pubkey), is_mayhem_mode (bool), is_cashback_enabled (OptionBool), creator_fee_bps (OptionU64), is_holder_reward (OptionBool). The last two were added by the 2026-09-15 upgrade.OptionBoolandOptionU64are single-field Anchor structs with no presence tag — each serializes as its bare inner value, 1 byte and 8 bytes respectively, never a discriminated Option. is_cashback_enabled = [true]is rejected as of 2026-09-15, with error6082 CashbackDeprecated—create_v2can no longer mint a new cashback coin. Existing cashback coins are unaffected: they keep trading, keep accruing, and remain claimable, so every cashback code path in this repo (the legacy sell path's cashback branch,is_cashback_coinonTokenInfo, etc.) stays live and must not be treated as dead.- The trailing args are positional, not independently optional. Reaching
is_holder_reward(arg 8) means sendingis_cashback_enabled(6) andcreator_fee_bps(7) first, even when both are false/zero. The three committed fixtures show three different wire lengths: the blocksubscribe fixture omits all trailing args (0 bytes afteris_mayhem_mode), the "omitted fee bps" getTransaction fixture sendsis_cashback_enabledonly (1 byte), and the "with fee bps" getTransaction fixture sendsis_cashback_enabled+creator_fee_bps(9 bytes) — counted directly from each fixture's instruction data, 2026-09-15.is_holder_reward(the three-trailing-arg form) is legal per the IDL but has not been observed on the wire — UNVERIFIED whether it is ever sent. A decoder that reads a fixed number of trailing bytes raisesIndexErroron the shorter forms. Decode trailing args defensively and report a missing one as unset —utils/idl_parser.pydoes this for trailing option-typed args since #184 (uv run learning-examples/verify_create_v2_optional_args.pychecks it). 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).
Holder reward coins
create_v2 can set is_holder_reward so the creator fee is set aside for
holders instead of paid to a creator wallet. On such a coin
BondingCurve.creator holds a pump.fun address rather than the actual
creator's — that's expected, and the creator_vault derivation is unchanged
and still correct either way, since it derives from whatever creator the
curve carries. No trade instruction changed: buy, sell, buy_v2,
sell_v2 and the PumpSwap instructions take identical accounts and arguments
whether or not a coin is a holder-reward coin. Global.is_holder_reward_enabled
can switch creation off globally.
TokenInfo.is_holder_reward and TokenInfo.creator_fee_bps surface this to
the bot — see the field comments on TokenInfo in src/interfaces/core.py
(dated 2026-09-15) for what's been verified live: SOL- and USDC-paired coins
keep creator_fee_bps at 0 (checked on 21 SOL-paired and 10 USDC-paired
coins), while every custom-pair coin checked in the same pass carried a
nonzero value.
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.