docs: compact docs and docstrings, split CLAUDE.md per Anthropic guidance (#221)

This commit is contained in:
Anton Sauchyk
2026-09-23 19:00:03 +02:00
committed by GitHub
parent 2ba40c0771
commit de7fd2aa90
118 changed files with 1849 additions and 3239 deletions
+98 -579
View File
@@ -1,32 +1,37 @@
# 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.
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.
`AGENTS.md` is a symlink to this file, so Claude Code, Codex, Cursor, and Windsurf all read the same guide.
`AGENTS.md` is a symlink to this file, so Claude Code, Codex, Cursor and Windsurf read the same guide.
## Reference
Deeper notes, kept out of this file so they load only when needed:
| Doc | Read it before |
|---|---|
| [docs/pumpfun-protocol.md](docs/pumpfun-protocol.md) | touching account layouts, instruction args, fee recipients or quote assets |
| [docs/listeners-and-geyser.md](docs/listeners-and-geyser.md) | changing a listener, the geyser stubs or a decoder |
| [docs/cookbook-conventions.md](docs/cookbook-conventions.md) | adding or changing anything under `cookbook/` |
| [docs/regression-tests.md](docs/regression-tests.md) | picking which verifiers your change needs |
## Ground rules
- **Running a bot with real funds is how a change gets verified** — but only with
explicit approval for that session, and only after saying what will run and what
it costs. Iterate with `cookbook/` and the simulation scripts below, which move
no funds; they exercise the builders and listeners but skip `bot_runner`, config
loading, the trader loop, the exit strategy and cleanup, so they cannot close the
question on their own. A verification run covers every mode the change reaches —
each listener, `extreme_fast_mode` on *and* off, each `exit_strategy` — and ends
with the wallet holding SOL only.
- **Running a bot with real funds is how a change gets verified**, with explicit
approval for that session and after stating what will run and what it costs.
`cookbook/` and the simulations move no funds but skip `bot_runner`, config
loading, the trader loop, the exit strategy and cleanup, so they cannot close
the question. A verification run covers every mode the change reaches — each
listener, `extreme_fast_mode` on and off, each `exit_strategy` — and ends with
the wallet holding SOL only.
- **Never** touch `.env` or print its contents. `SOLANA_PRIVATE_KEY` is a live key.
- Don't commit anything from `logs/`.
- Test with a cookbook script before touching `src/`.
- Don't commit anything from `logs/`; test with a cookbook script before `src/`.
## Layout
```
src/ bot source — this dir is the import root (see below)
cookbook/ standalone scripts; each runs on its own, no bot config
solana/ chain-level basics + solana_transaction_status.py
pumpfun/{listen,read,trade,graduation,decode}/
trade/pumpfun_instructions_v2.py buy_v2/sell_v2 layouts, by their callers
pumpswap/ letsbonk/ legacy/
tests/regression/ one offline verifier per fixed bug; imports src/
tools/ dev harness — simulations, live round trips, benchmarks
bots/ one YAML per bot instance
@@ -35,89 +40,23 @@ 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...`. Cookbook
scripts are deliberately self-contained and don't import from `src` at all.
Don't "fix" one by rewiring it to import the bot — that is what `tools/` is for.
Two helpers are exempt, because both are things that must never drift between
copies. `cookbook/solana/solana_transaction_status.py` (the `meta.err` check) is
used by every platform, so it sits with the other chain-level scripts.
`cookbook/pumpfun/trade/pumpfun_instructions_v2.py` (the buy_v2/sell_v2 account
layouts) sits with its only callers, who import it as a plain sibling; `legacy/`
reaches into that directory for it. Both are imported under a short alias
(`as pump_v2`, `as tx_status`) so call sites stay readable. A script that needs
one adds that directory to `sys.path` first; the geyser scripts add the repo root
too, for `src.geyser.generated`.
`src/` on `sys.path`, so it is `from core.client import SolanaClient`, not
`from src.core...`. Cookbook scripts don't import from `src` at all — don't
rewire one to; that is what `tools/` is for.
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`.
`interfaces` is the leaf; `geyser` holds only generated stubs; `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
Platform differences resolve through `interfaces/core.py` abstractions
(`AddressProvider`, curve manager, event parser, instruction builder) and the
registry in `platforms/__init__.py`. Listeners and the trader are
platform-agnostic (`Universal*`); anything platform-shaped belongs under
`platforms/<name>/`.
### What belongs in `cookbook/`
- **One script, one action.** A newcomer should be able to open a single file and
see the whole thing. Duplication across scripts is the accepted cost of that —
don't factor shared helpers out of them. `pumpfun_instructions_v2.py` and
`solana_transaction_status.py` are the two deliberate exceptions and the list is
closed. Buy and sell never share a file; `pumpfun_create_and_buy_token_v2.py` is
the sole two-action script, because that pair is what people ask for.
- **Every script runs on its own**: `uv run cookbook/<path>`, reading `.env`. No
bot config, no import from `src/`. Anything that needs the bot goes in `tools/`;
anything that asserts a past bug stays fixed goes in `tests/regression/`.
- **Directories are single lowercase words** grouped by what you are doing —
`listen`, `read`, `trade`, `graduation`, `decode` — under a platform directory.
- **Files are `<protocol>_<verb>_<noun>[_<variant>].py`**, all snake_case:
`pumpfun_buy_token_v2.py`, `pumpfun_listen_tokens_geyser.py`,
`letsbonk_sell_token_exact_out.py`, `solana_read_balances.py`. The protocol
repeats what the directory already says, on purpose — a basename is what shows
up in an editor tab, a grep hit or a docs link.
- protocol: `pumpfun`, `pumpswap`, `letsbonk`, `solana`, `anchor`
- verb: `buy`, `sell`, `create`, `snipe`, `listen`, `watch`, `read`, `derive`,
`decode`, `check`, `capture`, `find`
- noun: `token`, `price`, `curve`, `pool`, `balances`, `transaction`, `migrations`
- variant: instruction version (`v1`, `v2`, `exact_in`, `exact_out`) or transport
- the two non-runnable helpers take no verb, because they do nothing:
`pumpfun_instructions_v2.py`, `solana_transaction_status.py`
- **RPC and service names are lowercased into one token**, never camelCase:
`blocksubscribe`, `logsubscribe`, `programsubscribe`, `getaccountinfo`,
`gettransaction`, `pumpportal`. So `pumpfun_decode_transaction_gettransaction.py`,
not `..._getTransaction.py`.
- **Anything not specific to a launchpad belongs under `solana/`**, not `pumpfun/`.
- **Input is a command-line argument, never a constant you edit.** Every script
builds an `ArgumentParser` in `main()`: required values are positionals, tunables
are `--options`, and anything the caller varies per run — mint, wallet, amount,
slippage, fixture path — is one of them. Constants named `DEFAULT_*` supply the
defaults and are the only place a literal belongs.
- A placeholder is not a default. `TOKEN_MINT = "..."` reads as optional but
`Pubkey.from_string("...")` raises at import, so the script dies before
printing its own usage. Nine scripts did this.
- Don't read config from environment variables either — `.env` is for
endpoints and keys, not for trade parameters no usage line mentions.
- `sys.argv` never appears at module level. The seven listeners take no input
at all and are exempt; they are listed in the verifier.
- Fixtures keep their own form, `raw_<what>_from_<method>.json`, next to the script
that reads them.
- **Cite a URL only after checking it resolves.** Three rotted unnoticed by
2026-09-22 — Anchor restructured its docs and two Chainstack pages moved.
`uv run tests/regression/verify_documentation_links.py --live` fetches every URL
in the repo; run it when adding one.
- **A script that spends says so on the first line of its docstring**, and the
cookbook README marks it. The name is not a safety signal: every `*_buy_*`,
`*_sell_*`, `*_create_*` and `*_snipe_*` script submits real transactions. Read
the docstring before running anything.
## Commands
```bash
@@ -125,6 +64,7 @@ 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
uv run tests/regression/run_all.py # every offline verifier; moves no funds
```
Lint and format **the files you touched**, not the whole tree:
@@ -133,191 +73,63 @@ 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 ~1710 pre-existing errors across the repo
(ruff 0.16.8; 0.12.4 counted ~1680, the rules moved, not the code).
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.
A bare `uv run ruff check` reports a large backlog of pre-existing errors — the
known baseline, not a failing build and not something your change caused. Ruff
config is in `pyproject.toml`: line length 88, double quotes, target py311,
`E501` ignored. Type-hint public functions and use `get_logger(__name__)`.
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.
**Docstrings and comments carry what the code cannot.** A one-line summary, then
only what a reader could not get from the signature: units, ranges, what `None`
means, which errors a caller must handle, a constraint that stops the next
person breaking it. An `Args:` entry that restates the parameter name, or a
comment that restates the line under it, is noise and gets deleted. Leave out
rationale nobody acts on, hypotheticals about what might change, measurements,
dates and issue numbers.
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
`cookbook/` — the last one drifted out of sync with the protos.
### Where the Solana libraries live
The vendored protos are pinned to yellowstone **`v16.0.0-rc10+solana.4.3.0`**
(commit `301e9cf`, `yellowstone-grpc-proto` 13.0.0-rc4), which is the build the
endpoint itself reports from `GetVersion` — ask it before assuming, and pin to
what it answers. Not `master`: master has diverged and is *missing* things the
tag has (`block_footer`, `bank_id`, `VATDebit`). The committed stubs are
protobuf gencode **7.35.1** against runtime 7.36.2. Regenerating is not a plain
`protoc` run: the committed files import each other absolutely
(`from geyser.generated.solana_storage_pb2 import *`), which the compiler does
not emit on its own, so the output needs rewriting. Verified live under
grpcio 1.84 / protobuf 7.36.2 on 2026-09-23.
solana-py 0.40 moved most of them, and nothing in this class of break shows up
offline — an import sweep passes and the call fails against mainnet.
- `TxOpts` is `TxOptsModel`, and it, `MemcmpOpts` and `TokenAccountOpts` live in
`solana.rpc.core`, not `solana.rpc.types`.
- spl-token's `*Params` (`BurnParams`, `CloseAccountParams`, …) are in
`spl.token.models`; `spl.token.instructions` still holds the builders.
- Those are pydantic models: keyword-only and validating. `MemcmpOpts(bytes=...)`
wants the base58 str a Pubkey's `str()` gives; raw `bytes` raises.
- A bare `commitment="processed"` still works; `Commitment` is a str enum.
What the 13.0.0-rc4 protos changed, all verified live on 2026-09-23:
## Invariants
- **`Message.config` (field 7) is the only way to spot a transaction v1 over
geyser.** There is no version number on the wire and `versioned` is true for
v0 and v1 alike. The field is set only for v1 and carries the inline budget
(priority fee in **total lamports**, CU limit, loaded-accounts data size,
heap) that v1 moved off the ComputeBudget instructions — so a v1 coin has no
ComputeBudget instructions to read a budget from at all. A meaningful share
of pump.fun traffic is v1.
- Protos older than this **skipped `config` as an unknown field rather than
failing**, so the stale stubs degraded in silence: frames still decoded, only
the budget went missing. `verify_transaction_v1.py` now pins the geyser route
on its own committed frame so a rollback fails loudly.
- `TransactionStatusMeta.cost_units` and `Reward.commission_bps` are new too.
- **`CommitmentLevel` lost members 3-6**; they moved to a separate `SlotStatus`
enum. Nothing here sent anything but `PROCESSED`, so this cost nothing — but
it is the one breaking change in the upgrade.
- A yellowstone build **older than 15.1.1 silently downgrades v1 to v0 on the
wire**, which no local change can recover. Check `GetVersion` before
debugging a v1 gap.
`SubscribeDeshred` is a separate RPC delivering transactions **before
execution**, when entries form from shreds. It backs the **`shreds` listener**
(`monitoring/universal_shreds_listener.py`, pump.fun only,
`bots/bot-sniper-5-shreds.yaml`), which is off by default and carries one
permanent cost.
Raced against `Subscribe` on the pump.fun filter, deshred arrives first on most
creates by a small margin; `tools/compare_deshred_latency.py` reproduces the
comparison.
- There is no `TransactionStatusMeta`, so no `meta.log_messages`, so no
CreateEvent — the listener decodes the create instruction, which is the
fallback route everywhere else and the only route here. It sets
`state_from_event` itself so `extreme_fast_mode` submits with zero RPC:
waiting is not the safer option, because the curve account does not exist
yet, so a refresh can only time out and skip the coin.
- Outcomes are unknown at detection — nothing has executed, so a create that
goes on to revert is delivered exactly like one that lands.
- **A coin created through a router is invisible on it.** The create arrives as
a CPI, and inner instructions are produced *by* execution, so a pre-execution
stream never carries them — every one present on the stream and undetectable,
not dropped, so no decoding recovers them. Note this inverts the CPI note
under *Listener and decoder pitfalls*: **trades** are overwhelmingly inner
instructions, **creates** are overwhelmingly top-level, which is why deshred
looks nearly complete until you count.
That miss rate is the whole trade: a few percent of coins never seen, against a
head start on the rest. `geyser` remains the default for that reason; pick
`shreds` deliberately.
`cookbook/pumpfun/listen/pumpfun_listen_tokens_deshred.py` demonstrates the raw
stream, and `tests/regression/verify_shreds_listener.py` pins the listener.
Where the Solana libraries live, since solana-py 0.40 moved most of them
(upgraded 2026-09-23 from solders 0.26 / solana 0.36.6):
- `TxOpts` is **`TxOptsModel`**, and it, `MemcmpOpts` and `TokenAccountOpts` are
in `solana.rpc.core`, not `solana.rpc.types`.
- spl-token's `*Params` (`BurnParams`, `CloseAccountParams`, `SyncNativeParams`,
…) are in `spl.token.models`, not `spl.token.instructions`, which still holds
the instruction builders.
- All of those are **pydantic models now**, so they are keyword-only and they
validate: `MemcmpOpts(bytes=...)` wants the base58 **str** a Pubkey's `str()`
already gives, and raw `bytes` raises `ValidationError`. Nothing in this class
of break shows up offline — an import sweep passes and the call fails against
mainnet.
- A bare `commitment="processed"` string still works; `Commitment` is a str enum.
### Verifying a change
Every fix here ships with an offline verifier under `tests/regression/`. Each
script's docstring carries the bug it guards and the checks it runs — read that
before touching the code it covers, and run the ones your change reaches. None of
them move funds. `uv run tests/regression/run_all.py` runs the whole set, or
name individual scripts to run a subset.
| Script | Checks |
|---|---|
| `verify_v2_account_layout.py` | buy_v2/sell_v2 account layouts, PDA/ATA derivations, encoding — against `idl/pump_fun_idl.json` |
| `verify_curve_account_sizes.py` | 125/151/256-byte curves all decode, and nothing filters on account length |
| `verify_create_v2_optional_args.py` | omitted trailing option-typed `create_v2` args decode as unset; mandatory args still fail |
| `verify_transaction_v1.py` | every reader asks `maxSupportedTransactionVersion: 1`, a v1 `create_v2` is detected from its logs alone with the envelope made unreadable, the envelope decode still works as the fallback, and no cookbook/tools listener detects by opening the envelope; same two routes pinned again over geyser, on a committed v1 `SubscribeUpdate`, plus the inline v1 budget staying readable |
| `verify_shreds_listener.py` | pre-execution creates decode from the instruction alone: `user` at `create_v2` account 5, a holder-reward coin's creator derived as `PDA(["holder-rewards", mint])`, truncated trailing args decoding as not-holder-reward, lookup-table accounts resolved, and nothing reading a `meta` the stream has no field for |
| `verify_block_null_guard.py` | a `blockSubscribe` frame with `value.block: null` is skipped, not logged as an error |
| `verify_listener_cancellation.py` | a cancelled WebSocket listener stops, even when `websockets` reports cancellation as `AssertionError` |
| `verify_pumpportal_buy_path.py` | curve derived from the mint, unreadable curve skips the buy, curve+mint read in one slot-consistent batch |
| `verify_pumpportal_bonk_fields.py` | bonk payloads (no name/symbol/uri) still produce a `TokenInfo`; `--live` re-checks the real feed |
| `verify_extreme_fast_zero_rpc.py` | zero RPC calls between detection and submission for CreateEvent-sourced tokens |
| `verify_buy_result_not_lost.py` | a landed buy is never reported failed, and a reverted one never reported landed |
| `verify_tx_status_checks.py` | every path reads `meta.err`; `--live` replays the reverted signatures from #175 |
| `verify_tp_sl_exit_price.py` | the tp/sl exit prices off the trigger price, and a reverted sell is retried, bounded |
| `verify_time_based_exit_retry.py` | the default `time_based` exit retries a reverted sell instead of stranding the position |
| `verify_time_exit_without_price.py` | `max_hold_time` still fires when every price read fails |
| `verify_exit_sell_confirmation.py` | an exit sell is retried only when retrying is provably safe |
| `verify_rpc_deadline.py` | `post_rpc` bounds wall time, not just attempts (virtual clock) |
| `verify_quote_decimals_resolved.py` | no trade path prices a coin before resolving its quote mint's decimals |
| `verify_cookbook_arguments.py` | every cookbook script takes its input as a command-line argument |
| `verify_documentation_links.py` | no known-dead URL is back; `--live` fetches every one and fails on 4xx/5xx |
| `verify_no_rpc_credentials_logged.py` | credentials are masked in every log record, including a URL passed as a non-`str` argument, and every site that installs a root handler installs the redaction first |
| `verify_pumpswap_account_layout.py` | pump-amm's `pool-v2` account is gated on `coin_creator`, the buyback pair stays last, and base-token decimals are resolved rather than assumed; `--live` re-reads the authorized recipients from `GlobalConfig` |
Two mainnet simulations, also no funds moved:
```bash
uv run tools/simulate_v2_trades.py <MINT> # buy_v2/sell_v2 for one coin, reports CU
uv run tools/simulate_bot_buy_path.py # the bot's whole buy path, fresh coin
```
After any pump.fun program upgrade run `verify_v2_account_layout`,
`verify_curve_account_sizes` and both simulations, then retune
`get_buy_compute_unit_limit` / `get_sell_compute_unit_limit` in
`platforms/pumpfun/instruction_builder.py` from the reported `unitsConsumed`.
### Invariants
Rules that constrain code you might write next, rather than a bug already fixed.
The reasoning behind each lives in the verifier named beside it.
Rules that constrain code you might write next. The reasoning behind each lives
in the verifier named in [docs/regression-tests.md](docs/regression-tests.md).
**Listeners and RPC**
- `maxSupportedTransactionVersion` is a **whole-frame** setting. Asking
`blockSubscribe` for `0` does not skip the v1 transactions in a block, it nulls
`value.block` for the entire notification — indistinguishable from a skipped
`blockSubscribe` for `0` does not skip a block's v1 transactions, it nulls
`value.block` for the whole notification — indistinguishable from a skipped
slot, and a near-total outage of the blocks listener. Send `1` everywhere.
- **A listener routes on `meta.logMessages`, never on the envelope decode.**
The RPC has already decoded the envelope by the time it emits the logs, and
they read the same for every transaction version; the byte decode is the
fallback. The repo now runs solders 0.29, which *does* read a v1 envelope
(0.26, 0.27.1 and 0.28 all raise `ValueError: io error: unexpected end of
file` on the committed v1 fixture) — that does not make
the log route redundant, because the next version byte will be unreadable in
its turn. `verify_transaction_v1.py` pins both routes separately.
- **A listener routes on `meta.logMessages`, never on the envelope decode.** The
RPC has already decoded the envelope by the time it emits the logs, and they
read the same for every transaction version; the byte decode is the fallback.
- The bot **sends** v0 transactions (`MessageV0.try_compile` +
`VersionedTransaction`, no lookup tables), as of the 2026-09-23 dependency
upgrade. Nothing else builds a legacy `Message`.
- `post_rpc` must catch `asyncio.TimeoutError` alongside `aiohttp.ClientError` —
`VersionedTransaction`, no lookup tables). Nothing builds a legacy `Message`.
- `post_rpc` must catch `asyncio.TimeoutError` alongside `aiohttp.ClientError`:
aiohttp raises the former on a request timeout, it is not a `ClientError`, and
`str()` on it is empty, so the caller logs a blank reason.
- `post_rpc` bounds attempts; `deadline_seconds` (default `None`, the historical
behaviour) bounds wall time. Don't wrap a lookup in `asyncio.timeout` instead —
cutting off an in-flight `getTransaction` and returning None is the "can't see
it, so call it failed" conflation #206 removed.
- **The endpoints carry their API key in the URL, so any log line holding one
is a leak.** `install_secret_redaction` (`utils/logger.py`) masks the value at
log-record creation and every entry point calls it before attaching a handler.
Silencing the HTTP clients by name is a convenience on top, not the control:
`tools/cleanup_accounts.py` silenced `httpx` and `httpcore` and leaked anyway
once solana-py 0.40 switched to httpx2. Redact the **rendered** message, not
`record.msg` plus the string arguments — httpx2 passes its URL as a `URL`
object, so a type check skips the one argument that matters.
- `post_rpc` bounds attempts; `deadline_seconds` (default `None`) bounds wall
time. Don't wrap a lookup in `asyncio.timeout` instead — cutting off an
in-flight `getTransaction` and returning None conflates "can't see it" with
"it failed".
- `build_and_send_transaction` returns a solders `Signature`, not a `str`.
Normalize at the boundary: a `Signature` is neither JSON serializable nor
sliceable, and solana-py's `confirm_transaction` rejects a `str`.
- **The endpoints carry their API key in the URL, so any log line holding one is
a leak.** `install_secret_redaction` (`utils/logger.py`) masks the value at
log-record creation, and every entry point calls it before attaching a handler.
Redact the **rendered** message, not `record.msg` plus the string arguments —
httpx2 passes its URL as a `URL` object, so a type check skips the one argument
that matters.
**Buying**
@@ -325,27 +137,20 @@ The reasoning behind each lives in the verifier named beside it.
`resolve_quote_token_program` returns the token program and caches the mint's
decimals off the same read; `quote_units` raises rather than guessing, because
a wrong power of ten inflates the price *and* the slippage cap in the same
direction, so they compound into an overspend instead of cancelling. pump.fun's
`QuoteControl` registry (PDA `["quote-control"]`) admits mints at 6, 8 and 9
decimals — many of the admitted mints are tokenized equities, and coins
paired with them trade live.
direction, so they compound into an overspend instead of cancelling.
- `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 with `NotAuthorized` (6000),
read in `extreme_fast_mode`; when it expires the token is **skipped**, because
a buy built from listener-guessed defaults reverts with `NotAuthorized` (6000),
`ConstraintSeeds` (2006) or, on letsbonk, `AccountNotInitialized` (3012). The
sell path keeps the opposite fallback — proceed with cached values — since
skipping a sell strands the position.
- The refresh is skipped entirely when `TokenInfo.state_from_event` is set, i.e.
the listener resolved creator/flags/quote_mint for itself. The **CreateEvent**
parsers set it. Instruction-parsed `TokenInfo` does not, because
`args.creator` is user-supplied and post-2026-04-28 may differ from the
canonical `BC.creator` — but that gap is now understood rather than merely
feared, and it has exactly two causes: a **holder-reward** coin (derivable
from the mint, see below) and a post-launch `migrate_bonding_curve_creator`
(which happens *after* create, so it cannot affect a snipe). The `shreds`
listener sets the flag itself on that basis; it has no curve to read and no
choice. `trade.trust_create_event: false`
forces the refresh back on; PumpPortal payloads always refresh.
the listener resolved creator/flags/quote_mint for itself. The CreateEvent
parsers set it; instruction-parsed `TokenInfo` does not, because `args.creator`
is user-supplied and may differ from the canonical `BC.creator`. The `shreds`
listener sets the flag itself, having no curve to read.
`trade.trust_create_event: false` forces the refresh back on; PumpPortal
payloads always refresh.
**Selling**
@@ -359,12 +164,12 @@ The reasoning behind each lives in the verifier named beside it.
3, validated 1..100).
- An exit sell is **not idempotent**, so "it failed" is not enough to act on.
`confirm_transaction_detailed` / `verify_transaction_status` return
`ConfirmationStatus` (`SUCCESS` / `REVERTED` / `UNCONFIRMED`); a `REVERTED`
`ConfirmationStatus` (`SUCCESS` / `REVERTED` / `UNCONFIRMED`): a `REVERTED`
retries, an `UNCONFIRMED` signature is re-checked first. A missing
`failure_reason` means "unknown", never "reverted".
- `SUBMIT_FAILED` means "no transaction ever reached the chain", and nothing else
— it is the one reason besides `REVERTED` that retries without asking the chain.
A post-submission throw reports `UNCONFIRMED` with its signature.
- `SUBMIT_FAILED` means "no transaction ever reached the chain" and nothing else
— the one reason besides `REVERTED` that retries without asking the chain. A
post-submission throw reports `UNCONFIRMED` with its signature.
- `confirm_transaction` and `verify_transaction_succeeded` deliberately stay
bools. Returning the enum would be silent: every member is truthy, so every
`if await client.confirm_transaction(sig):` would start passing unconditionally.
@@ -373,309 +178,23 @@ The reasoning behind each lives in the verifier named beside it.
store a non-positive read as the last known price or floor a sell against one;
`0.0` also satisfies the stop-loss comparison.
### Listener and decoder pitfalls
Each of these was a live bug in the cookbook scripts, 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: `tools/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.
- **Never gate a listener's dispatch on decoding the transaction envelope.**
The envelope is the one part of a transaction whose format changes under you,
and the installed solders is only ever one version behind that.
`meta.logMessages` is already decoded by
the RPC and is version-agnostic, so route on it and keep the byte decode as a
fallback. See the Invariants above.
- **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: top-level pump
instructions are heavily outnumbered by 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
`cookbook/pumpfun/graduation/pumpfun_watch_graduating_*.py` examples.
- **Filtered `programSubscribe` on the pump program is the portable way to find
curves by state.** `dataSize` + `memcmp` are applied server-side, and it is
accepted even by the public `api.mainnet-beta.solana.com`. `memcmp` only matches
exact bytes, so it cannot express "reserves below X" — only a handful of fixed
cutoffs. Treat it as a bandwidth saver and do the real comparison client-side;
don't assume a threshold is being enforced upstream. Geyser's account filters
have the same shape and add the slot and signature.
- **Resolve a curve's mint under Token-2022, not SPL Token.** The curve account
has no mint field and `["bonding-curve", mint]` is not reversible, so the mint
comes from the associated bonding curve ATA — which is Token-2022 for every
`create_v2` coin. `get_token_accounts_by_owner` with the SPL Token program
returns an empty list for all of them, silently. Verified four for four on live
curves, each confirmed by re-deriving the curve PDA from the recovered mint.
- **`SetLoadedAccountsDataSizeLimit` must stay generous: 16 MB, not 512 KB.**
Verified by simulation on a Token-2022 mint with extensions — 512 KB and 4 MB
both fail `MaxLoadedAccountsDataSizeExceeded` with `unitsConsumed=0` (never
executed), while 16 MB reaches the buy instruction and is still 4x under the
64 MB default. solders has no builder for it; encode `struct.pack("<BI", 4, n)`
against the compute-budget program.
## Pump.fun protocol notes (gotchas)
The IDLs under `idl/` are vendored verbatim from `github.com/pump-fun/pump-public-docs`
(`idl/pump.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_mint` is `Pubkey::default()` (all zeros)
for SOL-paired coins; USDC (`EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`)
is one whitelisted entry in `Global`, but coins paired with Token-2022
quote mints are live on chain too. **Legacy `buy`/`sell` cannot trade
non-SOL-paired coins at all.**
- **`Global.whitelisted_quote_mints` is not the authoritative quote-mint
registry.** The 2026-09-15 upgrade added a `QuoteControl` account with its
own mint list, which is how a coin can pair with a mint `Global` never
lists. Error `6064` was 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_program` is the hot-path read used by event
parsing and address resolution. The bot warms this cache once at startup
for every configured quote mint, so `extreme_fast_mode`'s zero-RPC contract
between detection and submission still holds.
- **The quote mint's decimals are resolved from chain too, in the same read.**
Amounts like `max_sol_cost` and `min_sol_output` are in the quote mint's raw
units, so assuming 9 decimals for a 6-decimal mint overstates the cap by
1000x and effectively disables slippage protection. `getAccountInfo` returns
the owner and the mint data together, so decimals cost no extra call; they
are cached alongside the token program and pre-seeded for WSOL (9) and
USDC (6). A quote mint whose decimals cannot be resolved fails at startup
rather than silently mis-scaling a trade. The `decimals` byte sits at
offset 44 in both SPL Token and Token-2022 mints — extensions are appended
after the base struct and never move it (verified 2026-09-16 against
`jsonParsed` on six mints: plain SPL at 82 bytes and Token-2022 with
extensions at 405, 690 and 866 bytes, all matching).
- The bot uses **`buy_v2` (27 accounts)** and **`sell_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_v2` is
`buy_v2` minus `global_volume_accumulator`. Layouts live in `_BUY_V2_ACCOUNTS` /
`_SELL_V2_ACCOUNTS` in `platforms/pumpfun/instruction_builder.py` and are
machine-checked against the IDL by `tests/regression/verify_v2_account_layout.py`.
- v2 args carry **no `track_volume` OptionBool** (24-byte data: discriminator +
two u64). Volume tracking is unconditional now that `user_volume_accumulator`
is mandatory. `max_sol_cost`/`min_sol_output` are in the **quote mint's** raw
units — lamports for SOL, 1e-6 for USDC.
- Even for SOL-paired coins you must pass **wrapped SOL** as `quote_mint`, not
`Pubkey::default()`. Transfers still happen in native SOL, and the
`associated_quote_*` accounts are only seed-constrained — do **not** create the
user's WSOL ATA, it would burn ~0.002 SOL of rent for nothing.
- Fee recipients: 24 total, in three sets of 8 (`NORMAL_FEE_RECIPIENTS`,
`RESERVED_FEE_RECIPIENTS` for mayhem coins, `BUYBACK_FEE_RECIPIENTS`). Every
v2 buy/sell needs a `fee_recipient` **and** a `buyback_fee_recipient`. The set
is randomized per tx, per pump.fun's guidance on spreading program throughput.
- `sharing_config` (PDA `["sharing-config", base_mint]`) lives under the **pump
fees program**, not the pump program. Easy to derive against the wrong program.
### BondingCurve account layout
- **`create_v2` now allocates a 125-byte account, not 151.** The 36 reserved
padding bytes are gone, and three fields were appended after `quote_mint`:
`creator_fee_bps` (u64), `can_edit_creator_fee` (bool, reserved — always
false) and `is_holder_reward` (bool). Every field the bot reads sits before
where the padding used to start, and none of it moved.
- `extend_account` can 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_v2` allocates now (2026-09-15 upgrade) |
| 151 bytes | The old allocation size; still reachable via `extend_account` |
| 256 bytes | A rarer `extend_account` target, confirmed live 2026-09-15 |
`tests/regression/verify_curve_account_sizes.py` checks 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 `Pool` gained a trailing **`virtual_quote_reserves: i128`** (16 bytes,
offset 245). Pool fields end at 261; live accounts are **301 bytes** with
trailing padding. Quote against **effective** reserves:
`pool_quote_token_account.amount + virtual_quote_reserves`.
**Upstream's release note claims it is 0 on all pools — that is out of date.**
Verified on mainnet: pool `6Bv1JM1deBPe…` carries 17.584505433 SOL of virtual
reserves against a 148.455 SOL vault, so quoting off the raw vault balance
under-prices by ~10.6%. It is `i128`, not `u64` — reading 8 bytes happens to
work only while the high half is zero.
- pump-amm has **no** `buy_v2`/`sell_v2`. The AMM instruction names are
unchanged; only the pool layout and quoting moved.
- **`Pool.coin_creator` tells you whether a pool is canonical**, i.e. graduated
from a pump.fun bonding curve. It is `Pubkey::default()` on every other pool.
Two things follow, and they hid each other until 2026-09-23:
- `pool-v2` is sent **only for a canonical pool**. The buyback fee recipient
and its quote ATA are always the last two accounts and are read
positionally, so adding `pool-v2` on a non-canonical pool shifts them and
the program rejects the `pool-v2` PDA with `BuybackFeeRecipientNotAuthorized`
(6053). Upstream's BREAKING_FEE_RECIPIENT.md says the pair goes after
`pool-v2` "for coins that graduate from bonding curve", and separately that
the pair is needed either way — the qualifier is the whole rule. Account
counts: buy 26/27 canonical, 25/26 not; sell 24/26 canonical, 23/25 not.
- **Do not assume 6 base-token decimals.** Every graduated coin has 6, but
non-canonical pools routinely carry 9, and a factor of 1000 scales the quote
and the slippage floor together, so a sell reverts `ExceededSlippage` (6004)
rather than just mispricing. Resolve the mint's owner and its decimals from
the same `getAccountInfo`, as `get_mint_info` does.
- The vendored `pump_swap_idl.json` **under-reports these accounts** and is
byte-identical to upstream (checked 2026-09-23), so it cannot be the
reference: it lists 23 for `buy` against 25-26 on chain, and 21 for `sell`
against 23-26.
### 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. `OptionBool` and `OptionU64` are 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 error
`6082 CashbackDeprecated` — `create_v2` can 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_coin` on `TokenInfo`,
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 sending `is_cashback_enabled` (6) and
`creator_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 after `is_mayhem_mode`), the
"omitted fee bps" getTransaction fixture sends `is_cashback_enabled` only
(1 byte), and the "with fee bps" getTransaction fixture sends
`is_cashback_enabled` + `creator_fee_bps` (9 bytes) — counted directly from
each fixture's instruction data, 2026-09-15. The three-trailing-arg form
**is** sent. Because the args are positional, a shorter form cannot have set
a later one — so a truncated instruction is proof the coin is not
holder-reward, not merely silence about it, which is what lets a
pre-execution listener classify every coin. A decoder that reads a
fixed number of trailing bytes raises `IndexError` on the shorter forms.
Decode trailing args defensively and report a missing one as unset —
`utils/idl_parser.py` does this for trailing option-typed args since #184
(`uv run tests/regression/verify_create_v2_optional_args.py` checks it).
- `create_v2` accounts 1-16 are in the IDL; accounts **17-19 are optional
remaining accounts** (`quote_mint`, `associated_quote_bonding_curve`,
`quote_token_program`). All three or none. This is the only way to read a new
coin's quote asset from the instruction rather than the event. In practice they
are appended for **SOL-paired coins too**, carrying wrapped SOL — a live
SOL-paired `create_v2` was observed with 19 accounts and account 17 = WSOL — so
do not treat a 19-account `create_v2` as proof of a non-SOL quote asset. Read
`quote_mint` off the curve instead.
- The **associated bonding curve is an ordinary ATA**, so its address depends on
which token program owns the mint: Token2022 for `create_v2` coins, SPL Token
for legacy `create`. Deriving with the wrong program returns a valid-looking
address that does not exist on chain. Verified: curve `3jJ83ND…` derives to
`Cd4iC3Jn…` under Token2022 (matches chain) and `AhNzZsBp…` under SPL Token.
- `extreme_fast_mode` skips the curve-state price fetch. Whether it also reads
the curve for mayhem/cashback/creator/**quote_mint** depends on provenance
(see the Invariants 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 populate
`quote_mint` from `CreateEvent` (which gained `quote_mint` and
`virtual_quote_reserves` as 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.
**That substituted address is `PDA(["holder-rewards", mint])` under the pump
program** — the same account the IDL declares for `holder_rewards` on
`distribute_fee_to_holders`, which is how the ordinary `creator-vault` route
delivers the fee to the holder pool without a second payout path. It depends on
nothing but the mint, so `args.creator` being unusable on these coins costs no
RPC call to work around: `PumpFunAddresses.find_holder_reward_creator` derives
it. A meaningful share of launches are holder-reward, so this is not an edge
case. **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 by `env_file`.
Actual variable names are `SOLANA_NODE_RPC_ENDPOINT`,
`SOLANA_NODE_WSS_ENDPOINT`, `SOLANA_PRIVATE_KEY`, `GEYSER_*`.
- **PumpPortal sends a thinner payload for `bonk` pools than for `pump` ones.**
A bonk `create` carries no `name`, `symbol` or `uri` — verified against the
live feed 2026-09-16, which ran 24 pump creates and 7 bonk creates in 90s.
Requiring those fields rejected every bonk token that ever arrived (issue
#200), so only `mint` and `traderPublicKey` are required; everything else the
trade path needs is derived from the mint. Consequence worth knowing:
`filters.match_string` matches on name/symbol, so it can never match a bonk
token from this feed. `tests/regression/verify_pumpportal_bonk_fields.py`
checks this against committed fixtures, and `--live` re-checks it against the
real feed. The bonk **trade** path past detection is still unverified — see
issue #201.
- **PumpPortal does not report coins created in a transaction v1**, while its
coverage of v0 creates is near-complete. It is a third-party feed, so no local change recovers them — the coin is never sent. This compounds
the thin-bonk-payload gap below, and it means `pumpportal` is a sampling feed
now, not a complete one. `bots/bot-sniper-4-pp.yaml` carries the same warning.
- `src/config_loader.py` (repo root of the package, not under `core/`) validates the platform/listener pairing before startup:
pump.fun supports `logs`, `blocks`, `geyser`, `shreds`, `pumpportal`;
letsbonk.fun supports `blocks`, `geyser`, `pumpportal` — **not `logs`**, and
**not `shreds`** (its instruction parse path is unverified, see #201). Adding a listener
- Bot YAML supports `${VAR}` interpolation from the file named by `env_file`:
`SOLANA_NODE_RPC_ENDPOINT`, `SOLANA_NODE_WSS_ENDPOINT`, `SOLANA_PRIVATE_KEY`,
`GEYSER_*`.
- `src/config_loader.py` (package root, not under `core/`) validates the
platform/listener pairing before startup: pump.fun supports `logs`, `blocks`,
`geyser`, `shreds`, `pumpportal`; letsbonk.fun supports `blocks`, `geyser`,
`pumpportal` — not `logs`, and not `shreds` (issue #201). Adding a listener
means updating `PLATFORM_LISTENER_COMPATIBILITY` there too.
- Bots with `separate_process: true` run in their own process. One log file per
bot instance.
- **PumpPortal is a sampling feed, not a complete one.** It never sends a coin
created in a transaction v1, and a bonk `create` carries no `name`, `symbol` or
`uri` — so only `mint` and `traderPublicKey` are required, and
`filters.match_string` can never match a bonk token. The bonk trade path past
detection is unverified (issue #201).
- Bots with `separate_process: true` run in their own process, one log file each.
- `pump_bot` runs **every** `bots/*.yaml`, and three of the four committed configs
ship `enabled: false`. With all of them disabled it prints nothing and exits 0,
which is indistinguishable from a clean run — check that
`logs/<name>_<timestamp>.log` appeared before reading anything into a run.
indistinguishable from a clean run — check that `logs/<name>_<timestamp>.log`
appeared before reading anything into a run.