# Agent guide Solana trading bot for pump.fun and letsbonk.fun. Snipes newly created tokens and exits on a configured strategy. See [README.md](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_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//`. ### 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 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__from_.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 ```bash 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: ```bash uv run ruff check --fix && uv run ruff format ``` 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 ```bash # 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 # 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) ```bash # 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 ```bash # 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_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. ### Verifying the tp/sl exit path (issue #189) ```bash # 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 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("